From ed74d967c57e2a686d07e6587a6c896d4a4d304a Mon Sep 17 00:00:00 2001 From: Yavor Georgiev <289915+fealebenpae@users.noreply.github.com> Date: Thu, 1 Jun 2023 17:14:05 +0200 Subject: [PATCH 001/828] Initial MCK commit --- .dockerignore | 6 + .evergreen-periodic-builds.yaml | 503 +++ .evergreen.yml | 2762 +++++++++++++++ .githooks/pre-commit | 141 + .github/CODEOWNERS | 3 + .../PULL_REQUEST_TEMPLATE/internal_change.md | 3 + .github/PULL_REQUEST_TEMPLATE/release.md | 44 + .github/dependabot.yml | 12 + .github/pull_request_template.md | 28 + .gitignore | 72 + .gitmodules | 3 + Makefile | 366 ++ PROJECT | 36 + README.md | 107 + RELEASE_NOTES.md | 555 +++ api/v1/addtoscheme_mongodb_v1.go | 6 + api/v1/apis.go | 13 + api/v1/customresource_readwriter.go | 15 + api/v1/doc.go | 5 + api/v1/kmip.go | 55 + api/v1/mdb/doc.go | 4 + api/v1/mdb/groupversion_info.go | 20 + api/v1/mdb/mongodb_roles_validation.go | 301 ++ api/v1/mdb/mongodb_types.go | 1402 ++++++++ api/v1/mdb/mongodb_types_test.go | 356 ++ api/v1/mdb/mongodb_validation.go | 251 ++ api/v1/mdb/mongodb_validation_test.go | 151 + api/v1/mdb/mongodbbuilder.go | 218 ++ api/v1/mdb/mongodconfig.go | 115 + api/v1/mdb/mongodconfig_test.go | 35 + api/v1/mdb/podspecbuilder.go | 155 + api/v1/mdb/shardedcluster.go | 53 + api/v1/mdb/wrap.go | 152 + api/v1/mdb/zz_generated.deepcopy.go | 1174 +++++++ api/v1/mdbmulti/doc.go | 4 + api/v1/mdbmulti/groupversion_info.go | 20 + api/v1/mdbmulti/mongodb_multi_types.go | 655 ++++ api/v1/mdbmulti/mongodbmulti_validation.go | 91 + .../mdbmulti/mongodbmulti_validation_test.go | 81 + api/v1/mdbmulti/mongodbmultibuilder.go | 92 + api/v1/mdbmulti/zz_generated.deepcopy.go | 247 ++ api/v1/om/appdb_types.go | 454 +++ api/v1/om/doc.go | 4 + api/v1/om/groupversion_info.go | 20 + api/v1/om/opsmanager_types.go | 794 +++++ api/v1/om/opsmanager_types_test.go | 173 + api/v1/om/opsmanager_validation.go | 190 ++ api/v1/om/opsmanager_validation_test.go | 229 ++ api/v1/om/opsmanagerbuilder.go | 219 ++ api/v1/om/zz_generated.deepcopy.go | 652 ++++ api/v1/register.go | 19 + api/v1/status/doc.go | 1 + api/v1/status/option.go | 103 + api/v1/status/part.go | 11 + api/v1/status/phase.go | 27 + api/v1/status/resourcenotready.go | 23 + api/v1/status/scaling_status.go | 55 + api/v1/status/status.go | 49 + api/v1/status/warnings.go | 27 + api/v1/status/zz_generated.deepcopy.go | 81 + api/v1/user/doc.go | 4 + api/v1/user/groupversion_info.go | 20 + api/v1/user/mongodbuser_types.go | 198 ++ api/v1/user/mongodbuser_types_test.go | 42 + api/v1/user/zz_generated.deepcopy.go | 179 + api/v1/validation.go | 52 + api/v1/zz_generated.deepcopy.go | 69 + config/crd/bases/mongodb.com_mongodb.yaml | 972 ++++++ .../mongodb.com_mongodbmulticluster.yaml | 754 +++++ .../crd/bases/mongodb.com_mongodbusers.yaml | 161 + config/crd/bases/mongodb.com_opsmanagers.yaml | 1075 ++++++ config/crd/kustomization.yaml | 13 + config/crd/kustomizeconfig.yaml | 19 + config/default/kustomization.yaml | 25 + config/manager/kustomization.yaml | 12 + ...godb-enterprise.clusterserviceversion.yaml | 440 +++ config/manifests/kustomization.yaml | 3 + config/rbac/appdb_role.yaml | 19 + config/rbac/appdb_role_binding.yaml | 11 + config/rbac/appdb_sa.yaml | 6 + config/rbac/database_sa.yaml | 5 + config/rbac/kustomization.yaml | 23 + config/rbac/mongodb_editor_role.yaml | 24 + config/rbac/mongodb_viewer_role.yaml | 20 + .../rbac/mongodbopsmanager_editor_role.yaml | 24 + .../rbac/mongodbopsmanager_viewer_role.yaml | 20 + config/rbac/mongodbusers_editor_role.yaml | 24 + config/rbac/mongodbusers_viewer_role.yaml | 20 + config/rbac/om_sa.yaml | 5 + config/rbac/role.yaml | 113 + config/rbac/role_binding.yaml | 24 + config/samples/kustomization.yaml | 6 + config/samples/mongodb-multi.yaml | 20 + config/samples/mongodb-om.yaml | 15 + config/samples/mongodb-replicaset.yaml | 13 + config/samples/mongodb-sharded.yaml | 16 + config/samples/mongodb-user.yaml | 14 + config/scorecard/bases/config.yaml | 7 + config/scorecard/kustomization.yaml | 16 + config/scorecard/patches/basic.config.yaml | 10 + config/scorecard/patches/olm.config.yaml | 50 + controllers/controller.go | 101 + controllers/controller_test.go | 44 + controllers/om/agent.go | 57 + controllers/om/api/admin.go | 407 +++ controllers/om/api/digest.go | 71 + controllers/om/api/http.go | 299 ++ controllers/om/api/http_test.go | 20 + controllers/om/api/initializer.go | 173 + controllers/om/api/initializer_test.go | 90 + controllers/om/api/mockedomadmin.go | 253 ++ controllers/om/apierror/api_error.go | 88 + controllers/om/appdb_process.go | 45 + controllers/om/appdb_process_test.go | 56 + controllers/om/automation_config.go | 445 +++ controllers/om/automation_config_test.go | 884 +++++ controllers/om/automation_status.go | 78 + controllers/om/backup/backup.go | 159 + controllers/om/backup/backup_config.go | 65 + controllers/om/backup/daemonconfig.go | 35 + controllers/om/backup/datastoreconfig.go | 66 + controllers/om/backup/group_config.go | 48 + .../om/backup/mongodbresource_backup.go | 296 ++ .../om/backup/mongodbresource_backup_test.go | 49 + controllers/om/backup/s3config.go | 162 + controllers/om/backup/snapshot_schedule.go | 79 + .../om/backup/snapshot_schedule_test.go | 65 + controllers/om/backup_agent_config.go | 95 + controllers/om/backup_agent_test.go | 79 + controllers/om/backup_test.go | 36 + controllers/om/deployment.go | 1217 +++++++ controllers/om/deployment/om_deployment.go | 10 + .../om/deployment/om_deployment_test.go | 39 + controllers/om/deployment/testing_utils.go | 43 + controllers/om/deployment_test.go | 824 +++++ controllers/om/depshardedcluster_test.go | 502 +++ controllers/om/fullreplicaset.go | 92 + controllers/om/fullreplicaset_test.go | 208 ++ controllers/om/group.go | 25 + controllers/om/host/hosts.go | 51 + controllers/om/host/monitoring.go | 69 + controllers/om/mockedomclient.go | 740 ++++ controllers/om/monitoring_agent_config.go | 94 + controllers/om/monitoring_agent_test.go | 50 + controllers/om/omclient.go | 912 +++++ controllers/om/omclient_test.go | 207 ++ controllers/om/ompaginator.go | 80 + controllers/om/ompaginator_test.go | 82 + controllers/om/organization.go | 22 + controllers/om/process.go | 608 ++++ controllers/om/process/om_process.go | 75 + controllers/om/process_test.go | 320 ++ controllers/om/replicaset.go | 335 ++ controllers/om/replicaset/om_replicaset.go | 109 + .../om/replicaset/om_replicaset_test.go | 30 + controllers/om/replicaset_test.go | 84 + controllers/om/shardedcluster.go | 258 ++ controllers/om/test_utils.go | 17 + .../om/testdata/automation_config.json | 324 ++ controllers/om/testdata/backup_config.json | 29 + controllers/om/testdata/deployment_tls.json | 161 + .../om/testdata/monitoring_config.json | 27 + controllers/operator/agents/agents.go | 171 + controllers/operator/agents/upgrade.go | 152 + .../operator/appdbreplicaset_controller.go | 1004 ++++++ .../appdbreplicaset_controller_test.go | 729 ++++ .../operator/authentication/authentication.go | 656 ++++ .../configure_authentication_test.go | 369 ++ controllers/operator/authentication/ldap.go | 132 + .../operator/authentication/ldap_test.go | 81 + controllers/operator/authentication/pkix.go | 262 ++ .../operator/authentication/scramsha.go | 157 + .../authentication/scramsha_credentials.go | 181 + .../scramsha_credentials_test.go | 25 + .../operator/authentication/scramsha_test.go | 74 + controllers/operator/authentication/x509.go | 190 ++ .../operator/authentication/x509_test.go | 64 + controllers/operator/authentication_test.go | 941 ++++++ .../automationconfig/automationconfig.go | 44 + .../automationconfig/automationconfig_test.go | 104 + .../operator/backup_snapshot_schedule_test.go | 147 + .../operator/certs/cert_configurations.go | 291 ++ .../operator/certs/certificate_test.go | 73 + controllers/operator/certs/certificates.go | 403 +++ controllers/operator/common_controller.go | 1080 ++++++ .../operator/common_controller_test.go | 469 +++ .../connection/opsmanager_connection.go | 76 + .../connectionstring/connectionstring.go | 220 ++ .../operator/construct/appdb_construction.go | 627 ++++ .../construct/appdb_construction_test.go | 110 + .../operator/construct/backup_construction.go | 220 ++ .../construct/backup_construction_test.go | 101 + .../operator/construct/construction_test.go | 433 +++ .../construct/database_construction.go | 949 ++++++ .../construct/database_construction_test.go | 329 ++ .../operator/construct/database_volumes.go | 137 + controllers/operator/construct/jvm.go | 117 + .../multicluster/multicluster_replicaset.go | 108 + .../multicluster_replicaset_test.go | 270 ++ .../construct/opsmanager_construction.go | 663 ++++ .../opsmanager_construction_common.go | 22 + .../construct/opsmanager_construction_test.go | 456 +++ controllers/operator/construct/pvc.go | 57 + .../construct/resourcerequirements.go | 69 + .../construct/resourcerequirements_test.go | 113 + .../operator/construct/testing_utils.go | 12 + .../controlledfeature/controlled_feature.go | 112 + .../controlled_feature_test.go | 31 + .../controlledfeature/feature_by_mdb.go | 60 + .../controlledfeature/feature_by_mdb_test.go | 60 + controllers/operator/create/create.go | 309 ++ controllers/operator/create/create_test.go | 335 ++ .../operator/database_statefulset_options.go | 62 + .../operator/inspect/statefulset_inspector.go | 63 + .../inspect/statefulset_inspector_test.go | 48 + controllers/operator/ldap/ldap_types.go | 21 + controllers/operator/mock/mockedkubeclient.go | 805 +++++ controllers/operator/mock/test_fixtures.go | 26 + .../mongodbmultireplicaset_controller.go | 1122 +++++++ .../mongodbmultireplicaset_controller_test.go | 949 ++++++ .../operator/mongodbopsmanager_controller.go | 1698 ++++++++++ .../mongodbopsmanager_controller_test.go | 1082 ++++++ .../mongodbopsmanager_event_handler.go | 30 + .../operator/mongodbreplicaset_controller.go | 536 +++ .../mongodbreplicaset_controller_test.go | 848 +++++ .../operator/mongodbresource_event_handler.go | 37 + .../mongodbshardedcluster_controller.go | 1017 ++++++ .../mongodbshardedcluster_controller_test.go | 1218 +++++++ .../operator/mongodbstandalone_controller.go | 364 ++ .../mongodbstandalone_controller_test.go | 291 ++ .../operator/mongodbuser_controller.go | 417 +++ .../operator/mongodbuser_controller_test.go | 510 +++ .../operator/mongodbuser_eventhandler.go | 26 + controllers/operator/namespace_watched.go | 55 + .../operator/namespace_watched_test.go | 35 + .../operator/operator_configuration.go | 11 + controllers/operator/pem/pem_collection.go | 182 + .../operator/pem/pem_collection_test.go | 79 + controllers/operator/pem/secret.go | 53 + controllers/operator/pem_test.go | 176 + controllers/operator/project/credentials.go | 57 + controllers/operator/project/project.go | 223 ++ controllers/operator/project/projectconfig.go | 106 + .../operator/project/projectconfig_test.go | 162 + controllers/operator/secrets/secrets.go | 180 + .../operator/testdata/certificates/cert_auto | 137 + .../testdata/certificates/cert_rfc2253 | 21 + .../certificates/certificate_then_key | 79 + .../testdata/certificates/just_certificate | 27 + .../operator/testdata/certificates/just_key | 52 + .../certificates/key_then_certificate | 79 + .../operator/testdata/version_manifest.json | 192 ++ .../operator/watch/config_change_handler.go | 133 + .../watch/config_change_handler_test.go | 67 + controllers/operator/watch/predicates.go | 179 + controllers/operator/watch/predicates_test.go | 90 + .../operator/watch/resource_watcher.go | 133 + controllers/operator/workflow/disabled.go | 18 + controllers/operator/workflow/failed.go | 82 + controllers/operator/workflow/invalid.go | 78 + controllers/operator/workflow/ok.go | 59 + controllers/operator/workflow/pending.go | 79 + controllers/operator/workflow/reconciling.go | 69 + controllers/operator/workflow/status.go | 65 + controllers/operator/workflow/status_test.go | 22 + controllers/operator/workflow/unsupported.go | 18 + controllers/operator/workflow/workflow.go | 21 + deploy/crd-and-csv-generation.md | 58 + deploy/crds/samples/ops-manager.yaml | 36 + .../crds/samples/replica-set-scram-user.yaml | 22 + deploy/crds/samples/replica-set.yaml | 20 + deploy/helm-charts | 1 + .../mongodb-enterprise.v1.7.0.zip | Bin 0 -> 290885 bytes .../mongodb-enterprise.v1.7.1.zip | Bin 0 -> 305894 bytes .../mongodb-enterprise.v1.8.0.zip | Bin 0 -> 345416 bytes docker/Dockerfile | 0 docker/cluster-cleaner/Chart.yaml | 3 + docker/cluster-cleaner/Dockerfile | 6 + docker/cluster-cleaner/Makefile | 24 + docker/cluster-cleaner/readme.md | 35 + .../clean-cluster-roles-and-bindings.sh | 24 + .../scripts/clean-failed-namespaces.sh | 29 + .../scripts/clean-ops-manager.sh | 11 + .../scripts/construction-site.sh | 9 + .../scripts/create-cluster-ca-as-configmap.sh | 10 + .../scripts/delete-old-builder-pods.sh | 23 + .../cluster-cleaner/scripts/is_older_than.py | 47 + docker/cluster-cleaner/templates/job.yaml | 159 + .../templates/ops_manager_cleaner_job.yaml | 48 + .../4.0/ubi/Dockerfile | 85 + .../4.0/ubi/mongodb-org-4.0.repo | 6 + .../4.0/ubuntu/Dockerfile | 125 + .../4.2/ubi/Dockerfile | 87 + .../4.2/ubi/mongodb-org-4.2.repo | 6 + .../4.2/ubuntu/Dockerfile | 125 + .../4.4/ubi/Dockerfile | 91 + .../4.4/ubi/mongodb-org-4.4.repo | 6 + .../4.4/ubuntu/Dockerfile | 125 + .../5.0/ubi/Dockerfile | 85 + .../5.0/ubi/mongodb-org-5.0.repo | 6 + .../5.0/ubuntu/Dockerfile | 123 + .../README.md | 8 + .../build_and_push_appdb_database_images.sh | 76 + .../docker-entrypoint.sh | 386 +++ .../licenses/LICENSE | 4 + .../Dockerfile.builder | 13 + .../Dockerfile.dcar | 18 + .../Dockerfile.template | 58 + .../Dockerfile.ubi | 40 + docker/mongodb-enterprise-database/LICENSE | 4 + docker/mongodb-enterprise-database/README.md | 44 + .../Dockerfile.builder | 23 + .../Dockerfile.dcar | 8 + .../Dockerfile.template | 42 + .../Dockerfile.ubi_minimal | 11 + .../content/LICENSE | 4 + .../content/agent-launcher-lib.sh | 138 + .../content/agent-launcher.sh | 159 + .../content/probe.sh | 32 + .../Dockerfile.builder | 14 + .../Dockerfile.dcar | 5 + .../Dockerfile.template | 25 + .../Dockerfile.ubi_minimal | 8 + .../LICENSE | 4 + .../backupdaemon_readiness.go | 79 + .../backupdaemon_readiness_test.go | 78 + .../go.mod | 14 + .../go.sum | 13 + .../edit_mms_configuration.go | 261 ++ .../edit_mms_configuration_test.go | 78 + .../scripts/backup-daemon-liveness-probe.sh | 9 + .../scripts/docker-entry-point.sh | 73 + .../Dockerfile.builder | 41 + .../Dockerfile.dcar | 18 + .../Dockerfile.template | 40 + .../Dockerfile.ubi | 11 + docker/mongodb-enterprise-operator/LICENSE | 4 + docker/mongodb-enterprise-operator/README.md | 21 + .../mongodb-enterprise-operator/content/.keep | 1 + .../licenses/Apache-2.0/LICENSE | 60 + .../licenses/Apache-2.0/README | 49 + .../licenses/BSD-2-Clause/LICENSE | 9 + .../licenses/BSD-2-Clause/README | 3 + .../licenses/BSD-3-Clause/LICENSE | 29 + .../licenses/BSD-3-Clause/README | 37 + .../licenses/ISC/LICENSE | 8 + .../licenses/ISC/README | 3 + .../licenses/MIT/LICENSE | 9 + .../licenses/MIT/README | 29 + .../licenses/MPL-2.0/LICENSE | 111 + .../licenses/MPL-2.0/README | 7 + .../licenses/THIRD-PARTY-NOTICES | 18 + .../Dockerfile.builder | 3 + .../Dockerfile.dcar | 25 + .../Dockerfile.template | 59 + .../Dockerfile.ubi | 23 + docker/mongodb-enterprise-ops-manager/LICENSE | 4 + docker/mongodb-enterprise-tests/.dockerignore | 3 + docker/mongodb-enterprise-tests/.flake8 | 5 + docker/mongodb-enterprise-tests/.pylintrc | 10 + docker/mongodb-enterprise-tests/Dockerfile | 50 + docker/mongodb-enterprise-tests/README.md | 239 ++ .../kubetester/__init__.py | 457 +++ .../kubetester/automation_config_tester.py | 133 + .../kubetester/awss3client.py | 53 + .../kubetester/certs.py | 835 +++++ .../kubetester/create_or_replace_from_yaml.py | 118 + .../kubetester/crypto.py | 74 + .../kubetester/custom_podspec.py | 42 + .../kubetester/git.py | 6 + .../kubetester/helm.py | 195 ++ .../kubetester/http.py | 44 + .../kubetester/kmip.py | 200 ++ .../kubetester/kubetester.py | 1632 +++++++++ .../kubetester/ldap.py | 173 + .../kubetester/mongodb.py | 438 +++ .../kubetester/mongodb_multi.py | 101 + .../kubetester/mongodb_user.py | 100 + .../kubetester/mongotester.py | 641 ++++ .../kubetester/om_queryable_backups.py | 207 ++ .../kubetester/omtester.py | 633 ++++ .../kubetester/operator.py | 315 ++ .../kubetester/opsmanager.py | 615 ++++ .../kubetester/security_context.py | 31 + .../kubetester/test_identifiers.py | 46 + .../kubetester/vault.py | 0 .../mongodb-enterprise-tests/pyproject.toml | 4 + docker/mongodb-enterprise-tests/pytest.ini | 32 + .../requirements-dev.txt | 7 + .../mongodb-enterprise-tests/requirements.txt | 19 + .../results/myreport.xml | 1 + .../tests/__init__.py | 0 .../tests/authentication/__init__.py | 0 .../tests/authentication/conftest.py | 258 ++ .../fixtures/ldap/ldap-agent-auth.yaml | 32 + .../fixtures/ldap/ldap-replica-set-roles.yaml | 43 + .../fixtures/ldap/ldap-replica-set.yaml | 27 + .../ldap/ldap-sharded-cluster-user.yaml | 17 + .../fixtures/ldap/ldap-sharded-cluster.yaml | 38 + .../fixtures/ldap/ldap-user.yaml | 17 + .../fixtures/replica-set-basic.yaml | 14 + .../replica-set-explicit-scram-sha-1.yaml | 23 + ...t-scram-sha-256-x509-internal-cluster.yaml | 24 + .../fixtures/replica-set-scram-sha-256.yaml | 19 + .../fixtures/replica-set-scram.yaml | 21 + .../replica-set-tls-scram-sha-256.yaml | 21 + .../replica-set-x509-to-scram-256.yaml | 23 + .../fixtures/scram-sha-user.yaml | 22 + .../sharded-cluster-explicit-scram-sha-1.yaml | 26 + .../fixtures/sharded-cluster-scram-sha-1.yaml | 24 + ...r-scram-sha-256-x509-internal-cluster.yaml | 28 + .../sharded-cluster-scram-sha-256.yaml | 23 + .../sharded-cluster-tls-scram-sha-256.yaml | 25 + ...x509-internal-cluster-auth-transition.yaml | 27 + .../sharded-cluster-x509-to-scram-256.yaml | 27 + .../authentication/replica_set_agent_ldap.py | 194 ++ .../replica_set_custom_roles.py | 141 + .../replica_set_feature_controls.py | 121 + .../replica_set_ignore_unkown_users.py | 59 + .../tests/authentication/replica_set_ldap.py | 337 ++ .../replica_set_ldap_agent_client_certs.py | 230 ++ .../replica_set_ldap_group_dn.py | 109 + ...plica_set_ldap_group_dn_with_x509_agent.py | 126 + .../authentication/replica_set_ldap_tls.py | 81 + .../replica_set_ldap_user_to_dn_mapping.py | 86 + .../replica_set_scram_sha_1_connectivity.py | 20 + .../replica_set_scram_sha_256_connectivity.py | 212 ++ .../replica_set_scram_sha_256_user_first.py | 85 + .../replica_set_scram_sha_and_x509.py | 182 + .../replica_set_scram_sha_upgrade.py | 54 + .../replica_set_scram_x509_ic_manual_certs.py | 84 + ...replica_set_scram_x509_internal_cluster.py | 52 + .../replica_set_update_roles_no_privileges.py | 134 + .../replica_set_x509_to_scram_transition.py | 187 ++ .../authentication/sha1_connectivity_tests.py | 153 + .../authentication/sharded_cluster_ldap.py | 78 + ...harded_cluster_scram_sha_1_connectivity.py | 20 + ...rded_cluster_scram_sha_256_connectivity.py | 127 + .../sharded_cluster_scram_sha_and_x509.py | 190 ++ .../sharded_cluster_scram_sha_upgrade.py | 44 + ...rded_cluster_scram_x509_ic_manual_certs.py | 84 + ...ded_cluster_scram_x509_internal_cluster.py | 60 + ...luster_x509_internal_cluster_transition.py | 60 + ...harded_cluster_x509_to_scram_transition.py | 190 ++ .../tests/clusterwideoperator/__init__.py | 0 .../tests/clusterwideoperator/conftest.py | 0 .../tests/clusterwideoperator/om_multiple.py | 86 + .../tests/conftest.py | 1023 ++++++ .../tests/docs/MINIO.md | 15 + .../tests/docs/img.png | Bin 0 -> 150083 bytes .../tests/docs/tls.crt | 31 + .../tests/docs/tls.key | 27 + .../tests/mixed/__init__.py | 0 .../all_mongodb_resources_parallel_test.py | 88 + .../tests/mixed/conftest.py | 4 + .../tests/mixed/crd_validation.py | 42 + .../tests/mixed/failures_on_multi_clusters.py | 142 + .../fixtures/sample-mdb-object-to-test.yaml | 12 + .../tests/multicluster/__init__.py | 29 + .../tests/multicluster/conftest.py | 269 ++ .../mongodb-multi-central-sts-override.yaml | 31 + .../fixtures/mongodb-multi-cluster.yaml | 20 + .../fixtures/mongodb-multi-dr.yaml | 39 + .../fixtures/mongodb-multi-split-horizon.yaml | 28 + .../fixtures/mongodb-multi-sts-override.yaml | 62 + .../multicluster/fixtures/mongodb-multi.yaml | 20 + .../multicluster/fixtures/mongodb-user.yaml | 22 + .../fixtures/mongodb-x509-user.yaml | 19 + .../fixtures/split-horizon-node-port.yaml | 15 + .../split-horizon-node-port.yaml | 18 + ..._cluster_tls_no_mesh_2_clusters_eks_gke.py | 144 + .../multi_2_cluster_clusterwide_replicaset.py | 327 ++ .../multi_2_cluster_replicaset.py | 101 + .../multicluster/multi_cluster_agent_flags.py | 52 + ...lti_cluster_automated_disaster_recovery.py | 125 + .../multi_cluster_backup_restore.py | 534 +++ .../multi_cluster_backup_restore_no_mesh.py | 691 ++++ .../multicluster/multi_cluster_cli_recover.py | 161 + .../multicluster/multi_cluster_clusterwide.py | 287 ++ .../multicluster/multi_cluster_dr_connect.py | 111 + .../multicluster/multi_cluster_enable_tls.py | 101 + .../tests/multicluster/multi_cluster_ldap.py | 262 ++ .../multi_cluster_ldap_custom_roles.py | 242 ++ .../multi_cluster_recover_clusterwide.py | 332 ++ ...multi_cluster_recover_network_partition.py | 116 + .../multicluster/multi_cluster_replica_set.py | 201 ++ .../multi_cluster_replica_set_deletion.py | 118 + ...luster_replica_set_ignore_unknown_users.py | 63 + ...ulti_cluster_replica_set_member_options.py | 224 ++ .../multi_cluster_replica_set_scale_down.py | 140 + .../multi_cluster_replica_set_scale_up.py | 143 + .../multi_cluster_replica_set_test_mtls.py | 230 ++ .../multi_cluster_s3_based_backup_restore.py | 200 ++ .../multi_cluster_scale_down_cluster.py | 144 + .../multi_cluster_scale_up_cluster.py | 140 + ...ti_cluster_scale_up_cluster_new_cluster.py | 175 + .../tests/multicluster/multi_cluster_scram.py | 222 ++ .../multi_cluster_split_horizon.py | 148 + .../multi_cluster_sts_override.py | 68 + .../multicluster/multi_cluster_tls_no_mesh.py | 246 ++ .../multi_cluster_tls_with_scram.py | 225 ++ .../multi_cluster_tls_with_x509.py | 227 ++ .../multi_cluster_upgrade_downgrade.py | 83 + .../multicluster/multi_cluster_validation.py | 37 + .../tests/olm/__init__.py | 0 .../olm/fixtures/olm_ops_manager_backup.yaml | 47 + .../olm/fixtures/olm_replica_set_for_om.yaml | 15 + .../olm_scram_sha_user_backing_db.yaml | 20 + .../fixtures/olm_sharded_cluster_for_om.yaml | 18 + .../tests/olm/olm_operator_upgrade.py | 53 + .../olm_operator_upgrade_with_resources.py | 357 ++ .../tests/olm/olm_test_commons.py | 111 + .../tests/operator/__init__.py | 0 .../tests/operator/operator_clusterwide.py | 237 ++ .../tests/operator/operator_partial_crd.py | 95 + .../tests/opsmanager/__init__.py | 0 .../backup_snapshot_schedule_tests.py | 207 ++ .../tests/opsmanager/conftest.py | 57 + .../opsmanager/fixtures/ca-tls-full-chain.crt | 131 + .../tests/opsmanager/fixtures/ca-tls.crt | 19 + .../tests/opsmanager/fixtures/ca-tls.key | 28 + .../downloads.mongodb.com.chained+root.crt | 78 + .../opsmanager/fixtures/mongodb-download.crt | 112 + .../fixtures/mongodb_versions_claim.yaml | 11 + .../om_appdb_configure_all_images.yaml | 18 + .../fixtures/om_appdb_scale_up_down.yaml | 17 + .../opsmanager/fixtures/om_appdb_scram.yaml | 15 + .../opsmanager/fixtures/om_appdb_upgrade.yaml | 18 + .../fixtures/om_backup_delete_sts.yaml | 37 + .../opsmanager/fixtures/om_https_enabled.yaml | 139 + .../fixtures/om_localmode-multiple-pv.yaml | 38 + .../fixtures/om_localmode-single-pv.yaml | 66 + .../om_ops_manager_appdb_monitoring_tls.yaml | 40 + .../om_ops_manager_appdb_upgrade_tls.yaml | 33 + .../fixtures/om_ops_manager_backup.yaml | 49 + .../fixtures/om_ops_manager_backup_irsa.yaml | 46 + .../fixtures/om_ops_manager_backup_kmip.yaml | 49 + .../fixtures/om_ops_manager_backup_light.yaml | 43 + .../fixtures/om_ops_manager_backup_tls.yaml | 43 + .../om_ops_manager_backup_tls_s3.yaml | 49 + .../fixtures/om_ops_manager_basic.yaml | 15 + .../fixtures/om_ops_manager_full.yaml | 32 + .../fixtures/om_ops_manager_jvm_params.yaml | 28 + .../fixtures/om_ops_manager_pod_spec.yaml | 113 + .../fixtures/om_ops_manager_scale.yaml | 26 + .../om_ops_manager_secure_config.yaml | 35 + .../fixtures/om_ops_manager_upgrade.yaml | 37 + .../fixtures/om_s3store_validation.yaml | 20 + .../opsmanager/fixtures/om_validation.yaml | 15 + .../remote_fixtures/nginx-config.yaml | 19 + .../fixtures/remote_fixtures/nginx-svc.yaml | 12 + .../fixtures/remote_fixtures/nginx.yaml | 68 + .../remote_fixtures/om_remotemode.yaml | 27 + .../fixtures/replica-set-for-om.yaml | 16 + .../opsmanager/fixtures/replica-set-kmip.yaml | 54 + .../fixtures/scram-sha-user-backing-db.yaml | 20 + .../fixtures/sharded-cluster-for-om.yaml | 19 + .../opsmanager/fixtures/upgrade_appdb.yaml | 16 + .../tests/opsmanager/om_appdb_multi_change.py | 45 + .../tests/opsmanager/om_appdb_scram.py | 251 ++ .../tests/opsmanager/om_appdb_validation.py | 192 ++ .../opsmanager/om_external_connectivity.py | 131 + .../tests/opsmanager/om_jvm_params.py | 86 + .../opsmanager/om_localmode_multiple_pv.py | 112 + .../opsmanager/om_localmode_single_pv.py | 106 + .../tests/opsmanager/om_ops_manager_backup.py | 761 +++++ .../om_ops_manager_backup_delete_sts.py | 85 + .../opsmanager/om_ops_manager_backup_irsa.py | 497 +++ .../opsmanager/om_ops_manager_backup_kmip.py | 157 + .../om_ops_manager_backup_manual.py | 398 +++ .../om_ops_manager_backup_restore.py | 262 ++ .../om_ops_manager_backup_restore_minio.py | 451 +++ .../om_ops_manager_backup_s3_tls.py | 115 + .../om_ops_manager_backup_sharded_cluster.py | 345 ++ .../opsmanager/om_ops_manager_backup_tls.py | 187 ++ .../om_ops_manager_backup_tls_custom_ca.py | 305 ++ .../om_ops_manager_feature_controls.py | 144 + .../tests/opsmanager/om_ops_manager_https.py | 181 + .../om_ops_manager_https_hybrid_mode.py | 116 + .../om_ops_manager_https_internet_mode.py | 127 + .../opsmanager/om_ops_manager_https_prefix.py | 50 + ...nable_and_disable_manually_deleting_sts.py | 105 + .../opsmanager/om_ops_manager_prometheus.py | 245 ++ .../om_ops_manager_queryable_backup.py | 566 ++++ .../tests/opsmanager/om_ops_manager_scale.py | 204 ++ ...ps_manager_update_before_reconciliation.py | 36 + .../opsmanager/om_ops_manager_upgrade.py | 424 +++ .../om_ops_manager_weak_password.py | 55 + .../tests/opsmanager/om_remotemode.py | 236 ++ .../tests/opsmanager/om_validation_webhook.py | 84 + .../opsmanager/withMonitoredAppDB/__init__.py | 0 .../opsmanager/withMonitoredAppDB/conftest.py | 7 + .../om_appdb_agent_flags.py | 175 + .../om_appdb_configure_all_images.py | 85 + .../om_appdb_scale_up_down.py | 144 + .../withMonitoredAppDB/om_appdb_upgrade.py | 188 ++ .../om_ops_manager_appdb_monitoring_tls.py | 150 + .../om_ops_manager_backup_light.py | 255 ++ .../om_ops_manager_backup_liveness_probe.py | 184 + .../om_ops_manager_pod_spec.py | 383 +++ .../om_ops_manager_secure_config.py | 202 ++ .../tests/probes/conftest.py | 15 + .../tests/probes/fixtures/deployment_tls.json | 1 + .../probes/replication_state_awareness.py | 141 + .../tests/replicaset/__init__.py | 0 .../tests/replicaset/conftest.py | 4 + .../fixtures/replica-set-8-members.yaml | 15 + .../fixtures/replica-set-custom-podspec.yaml | 50 + .../fixtures/replica-set-double.yaml | 12 + .../fixtures/replica-set-downgrade.yaml | 14 + .../replicaset/fixtures/replica-set-ent.yaml | 15 + .../replica-set-externally-exposed.yaml | 14 + .../fixtures/replica-set-invalid.yaml | 15 + .../fixtures/replica-set-liveness.yaml | 17 + .../fixtures/replica-set-mongod-options.yaml | 21 + .../fixtures/replica-set-pv-multiple.yaml | 27 + .../replicaset/fixtures/replica-set-pv.yaml | 18 + .../fixtures/replica-set-single.yaml | 16 + .../fixtures/replica-set-upgrade.yaml | 16 + .../replicaset/fixtures/replica-set.yaml | 15 + .../tests/replicaset/replica_set.py | 568 ++++ .../replicaset/replica_set_agent_flags.py | 38 + .../replicaset/replica_set_config_map.py | 29 + .../replicaset/replica_set_custom_podspec.py | 144 + .../tests/replicaset/replica_set_custom_sa.py | 45 + .../replica_set_exposed_externally.py | 66 + .../tests/replicaset/replica_set_groups.py | 145 + .../replicaset/replica_set_liveness_probe.py | 126 + .../replicaset/replica_set_member_options.py | 161 + .../replicaset/replica_set_mongod_options.py | 94 + .../replica_set_process_hostnames.py | 88 + .../tests/replicaset/replica_set_pv.py | 185 + .../replicaset/replica_set_pv_multiple.py | 120 + .../replicaset/replica_set_readiness_probe.py | 84 + .../tests/replicaset/replica_set_recovery.py | 49 + .../replica_set_report_pending_pods.py | 27 + .../replica_set_schema_validation.py | 171 + .../replica_set_statefulset_status.py | 45 + .../replica_set_update_delete_parallel.py | 47 + .../replica_set_upgrade_downgrade.py | 75 + .../tests/shardedcluster/__init__.py | 0 .../tests/shardedcluster/conftest.py | 4 + .../sharded-cluster-custom-podspec.yaml | 78 + .../fixtures/sharded-cluster-downgrade.yaml | 17 + .../sharded-cluster-mongod-options.yaml | 37 + .../fixtures/sharded-cluster-pv.yaml | 30 + .../sharded-cluster-scale-down-shards.yaml | 16 + .../sharded-cluster-scale-shards.yaml | 16 + .../fixtures/sharded-cluster-single.yaml | 20 + .../fixtures/sharded-cluster.yaml | 18 + .../tests/shardedcluster/sharded_cluster.py | 120 + .../sharded_cluster_agent_flags.py | 83 + .../sharded_cluster_custom_podspec.py | 91 + .../sharded_cluster_mongod_options.py | 149 + .../shardedcluster/sharded_cluster_pv.py | 115 + .../sharded_cluster_recovery.py | 40 + .../sharded_cluster_scale_down_shards.py | 54 + .../sharded_cluster_scale_shards.py | 84 + .../sharded_cluster_schema_validation.py | 149 + .../shardedcluster/sharded_cluster_secret.py | 29 + .../sharded_cluster_statefulset_status.py | 74 + .../sharded_cluster_upgrade_downgrade.py | 58 + .../tests/standalone/__init__.py | 0 .../tests/standalone/conftest.py | 4 + .../fixtures/standalone-custom-podspec.yaml | 31 + .../fixtures/standalone-downgrade.yaml | 12 + .../tests/standalone/fixtures/standalone.yaml | 13 + .../fixtures/standalone_pv_invalid.yaml | 17 + .../fixtures/test_storage_class.yaml | 11 + .../tests/standalone/standalone_config_map.py | 59 + .../standalone/standalone_custom_podspec.py | 36 + .../tests/standalone/standalone_groups.py | 88 + .../tests/standalone/standalone_recovery.py | 39 + .../standalone_schema_validation.py | 117 + .../standalone/standalone_set_agent_flags.py | 38 + .../standalone_type_change_recovery.py | 42 + .../standalone_upgrade_downgrade.py | 67 + .../tests/tls/__init__.py | 0 .../tests/tls/conftest.py | 4 + ...onfigure_tls_and_x509_simultaneously_rs.py | 56 + ...onfigure_tls_and_x509_simultaneously_sc.py | 63 + ..._tls_and_x509_simultaneously_standalone.py | 53 + .../tests/tls/e2e_tls_disable_and_scale_up.py | 51 + .../tests/tls/fixtures/node-port-service.yaml | 17 + .../tests/tls/fixtures/server-key.pem | 5 + .../tls/fixtures/test-no-tls-no-status.yaml | 16 + .../fixtures/test-tls-additional-domains.yaml | 21 + .../fixtures/test-tls-base-rs-allow-ssl.yaml | 25 + .../test-tls-base-rs-external-access.yaml | 25 + .../fixtures/test-tls-base-rs-prefer-ssl.yaml | 24 + ...est-tls-base-rs-require-ssl-custom-ca.yaml | 20 + .../test-tls-base-rs-require-ssl-upgrade.yaml | 22 + .../test-tls-base-rs-require-ssl.yaml | 19 + .../tls/fixtures/test-tls-base-rs-x509.yaml | 22 + .../tests/tls/fixtures/test-tls-base-rs.yaml | 19 + ...est-tls-base-sc-require-ssl-custom-ca.yaml | 22 + .../test-tls-base-sc-require-ssl.yaml | 21 + ...-rs-external-access-multiple-horizons.yaml | 28 + .../test-tls-sc-additional-domains.yaml | 23 + .../fixtures/test-x509-all-options-rs.yaml | 22 + .../fixtures/test-x509-all-options-sc.yaml | 25 + .../tests/tls/fixtures/test-x509-rs.yaml | 22 + .../tls/fixtures/test-x509-sc-custom-ca.yaml | 25 + .../tests/tls/fixtures/test-x509-sc.yaml | 24 + .../tests/tls/fixtures/test-x509-user.yaml | 19 + .../tests/tls/fixtures/x509-testing-user.csr | 7 + .../tests/tls/tls_allowssl.py | 47 + .../tls/tls_multiple_different_ssl_configs.py | 101 + .../tests/tls/tls_no_status.py | 38 + .../tests/tls/tls_permissions_default.py | 51 + .../tests/tls/tls_permissions_override.py | 71 + .../tests/tls/tls_preferssl.py | 48 + .../tls/tls_replica_set_process_hostnames.py | 112 + .../tls/tls_replicaset_certsSecretPrefix.py | 53 + .../tests/tls/tls_requiressl.py | 107 + .../tests/tls/tls_requiressl_and_disable.py | 168 + .../tests/tls/tls_requiressl_to_allow.py | 101 + .../tests/tls/tls_requiressl_upgrade.py | 69 + .../tests/tls/tls_rs_additional_certs.py | 91 + .../tests/tls/tls_rs_external_access.py | 200 ++ ..._rs_external_access_manual_connectivity.py | 121 + ...nal_access_transitions_without_approval.py | 88 + .../tests/tls/tls_rs_intermediate_ca.py | 46 + .../tests/tls/tls_sc_additional_certs.py | 77 + .../tests/tls/tls_sc_requiressl_custom_ca.py | 103 + .../tls_sharded_cluster_certsSecretPrefix.py | 62 + .../tls/tls_sharded_cluster_certs_prefix.py | 159 + .../tls/tls_x509_configure_all_options_rs.py | 53 + .../tls/tls_x509_configure_all_options_sc.py | 74 + .../tests/tls/tls_x509_rs.py | 65 + .../tests/tls/tls_x509_sc.py | 49 + .../tests/tls/tls_x509_user_connectivity.py | 111 + .../tests/upgrades/__init__.py | 0 .../upgrades/operator_upgrade_appdb_tls.py | 103 + .../upgrades/operator_upgrade_ops_manager.py | 177 + .../upgrades/operator_upgrade_replica_set.py | 116 + .../tests/users/__init__.py | 0 .../tests/users/conftest.py | 4 + .../tests/users/fixtures/user_with_roles.yaml | 13 + .../tests/users/fixtures/users_multiple.yaml | 73 + .../tests/users/users_addition_removal.py | 131 + .../tests/users/users_schema_validation.py | 44 + .../tests/vaultintegration/__init__.py | 64 + .../tests/vaultintegration/conftest.py | 162 + .../mongodb_deployment_vault.py | 475 +++ .../tests/vaultintegration/om_backup_vault.py | 477 +++ .../vaultintegration/om_deployment_vault.py | 386 +++ .../tests/vaultintegration/vault_tls.py | 322 ++ .../tests/webhooks/__init__.py | 0 .../tests/webhooks/conftest.py | 4 + .../e2e_mongodb_roles_validation_webhook.py | 242 ++ .../e2e_mongodb_validation_webhook.py | 142 + .../fixtures/invalid_appdb_shard_count.yaml | 0 .../fixtures/invalid_mdb_member_count.yaml | 18 + ...d_replica_set_agent_auth_not_in_modes.yaml | 23 + .../invalid_replica_set_auth_no_modes.yaml | 23 + .../invalid_replica_set_horizons_members.yaml | 28 + .../invalid_replica_set_horizons_tls.yaml | 25 + .../invalid_replica_set_ldap_community.yaml | 18 + ..._replica_set_ldapauthz_no_ldapgroupdn.yaml | 25 + .../invalid_replica_set_no_agent_mode.yaml | 21 + .../invalid_replica_set_x509_no_tls.yaml | 22 + .../fixtures/role-validation-base.yaml | 30 + .../vaultconfig/override.yaml | 29 + .../vaultpolicies/appdb-policy.hcl | 8 + .../vaultpolicies/database-policy.hcl | 8 + .../vaultpolicies/operator-policy.hcl | 8 + .../vaultpolicies/opsmanager-policy.hcl | 8 + .../mongodb-enterprise-tests/vendor/README.md | 10 + .../vendor/openldap/.helmignore | 21 + .../vendor/openldap/Chart.yaml | 16 + .../vendor/openldap/README.md | 100 + .../vendor/openldap/templates/NOTES.txt | 20 + .../vendor/openldap/templates/_helpers.tpl | 40 + .../templates/configmap-customldif.yaml | 23 + .../openldap/templates/configmap-env.yaml | 20 + .../vendor/openldap/templates/deployment.yaml | 174 + .../vendor/openldap/templates/pvc.yaml | 27 + .../vendor/openldap/templates/secret.yaml | 18 + .../vendor/openldap/templates/service.yaml | 44 + .../templates/tests/openldap-test-runner.yaml | 50 + .../templates/tests/openldap-tests.yaml | 22 + .../vendor/openldap/values.yaml | 117 + .../pod-is-killed-while-agent-restores.md | 208 ++ go.mod | 128 + go.sum | 518 +++ hack/boilerplate.go.txt | 15 + helm_chart/Chart.yaml | 15 + helm_chart/crds/mongodb.com_mongodb.yaml | 972 ++++++ .../crds/mongodb.com_mongodbmulticluster.yaml | 754 +++++ helm_chart/crds/mongodb.com_mongodbusers.yaml | 161 + helm_chart/crds/mongodb.com_opsmanagers.yaml | 1075 ++++++ helm_chart/templates/_helpers.tpl | 13 + helm_chart/templates/database-roles.yaml | 83 + helm_chart/templates/operator-roles.yaml | 178 + helm_chart/templates/operator.yaml | 245 ++ helm_chart/templates/secret-config.yaml | 23 + helm_chart/values-openshift.yaml | 197 ++ helm_chart/values.yaml | 114 + inventories/daily.yaml | 44 + inventories/database.yaml | 71 + inventories/init_appdb.yaml | 82 + inventories/init_database.yaml | 83 + inventories/init_om.yaml | 72 + inventories/om.yaml | 97 + inventories/test.yaml | 15 + inventory.yaml | 82 + licenses.csv | 105 + main.go | 349 ++ multi_cluster/cluster.yaml | 106 + multi_cluster/create_security_groups.py | 127 + .../setup_multi_cluster_environment.sh | 58 + multi_cluster/tools/README.md | 19 + multi_cluster/tools/download_istio.sh | 10 + multi_cluster/tools/install_istio.sh | 180 + multi_cluster/tools/install_istio_central.sh | 13 + pipeline.py | 782 +++++ pipeline_test.py | 38 + pkg/dns/dns.go | 124 + pkg/handler/enqueue_owner_multi.go | 53 + pkg/kube/kube.go | 30 + .../failedcluster/failedcluster.go | 11 + pkg/multicluster/memberwatch/clusterhealth.go | 96 + .../memberwatch/clusterhealth_test.go | 51 + pkg/multicluster/memberwatch/memberwatch.go | 229 ++ .../memberwatch/memberwatch_test.go | 149 + pkg/multicluster/mockedcluster.go | 65 + pkg/multicluster/multicluster.go | 163 + pkg/multicluster/multicluster_test.go | 136 + pkg/passwordhash/passwordhash.go | 29 + pkg/statefulset/statefulset_test.go | 583 ++++ pkg/statefulset/statefulset_util.go | 140 + pkg/tls/tls.go | 69 + pkg/util/constants.go | 309 ++ pkg/util/env/env.go | 131 + pkg/util/generate/generate.go | 48 + pkg/util/identifiable/identifiable.go | 90 + pkg/util/int/int_util.go | 17 + pkg/util/int/int_util_test.go | 20 + pkg/util/manifest/version.go | 110 + pkg/util/maputil/mapmerge.go | 63 + pkg/util/maputil/mapmerge_test.go | 96 + pkg/util/maputil/maputil.go | 138 + pkg/util/maputil/maputil_test.go | 81 + pkg/util/mergo_utils.go | 119 + pkg/util/stringutil/stringutil.go | 93 + pkg/util/stringutil/stringutil_test.go | 28 + pkg/util/timeutil/timeutil.go | 8 + pkg/util/util.go | 179 + pkg/util/util_test.go | 196 ++ pkg/util/versionutil/versionutil.go | 83 + pkg/util/versionutil/versionutil_test.go | 18 + pkg/vault/vault.go | 545 +++ pkg/vault/vaultwatcher/vaultsecretwatch.go | 113 + pkg/webhook/certificates.go | 108 + pkg/webhook/setup.go | 201 ++ production_notes/README.md | 57 + production_notes/cmd/runtest/pvc.yaml | 17 + production_notes/cmd/runtest/runtest.go | 429 +++ production_notes/cmd/setup/setup.go | 89 + production_notes/deploy/basic.yaml | 38 + production_notes/deploy/big.yaml | 39 + production_notes/deploy/medium.yaml | 39 + production_notes/deploy/small.yaml | 39 + production_notes/deploy/storageClass.yaml | 7 + production_notes/docker/Dockerfile | 8 + production_notes/docker/run.sh | 35 + .../helm_charts/mongodb/certs/.helmignore | 23 + .../helm_charts/mongodb/certs/Chart.yaml | 5 + .../helm_charts/mongodb/certs/ca_tls.crt | 33 + .../helm_charts/mongodb/certs/ca_tls.key | 52 + .../mongodb/certs/templates/ca-issuer.yaml | 10 + .../mongodb/certs/templates/ca-key-pair.yaml | 10 + .../mongodb/certs/templates/issuer-ca.yaml | 10 + .../mongodb/certs/templates/pod-certs.yaml | 20 + .../mongodb/replicaset/.helmignore | 23 + .../helm_charts/mongodb/replicaset/Chart.yaml | 4 + .../helm_charts/mongodb/replicaset/crds | 1 + .../mongodb/replicaset/templates/binding.yaml | 15 + .../replicaset/templates/database-cm.yaml | 19 + .../replicaset/templates/database-secret.yaml | 12 + .../replicaset/templates/database.yaml | 43 + .../templates/mongodb-user-password.yaml | 11 + .../replicaset/templates/mongodb-user.yaml | 18 + .../helm_charts/mongodb/values.yaml | 53 + .../helm_charts/operator/.helmignore | 23 + .../helm_charts/operator/Chart.yaml | 5 + production_notes/helm_charts/operator/crds | 1 + .../operator/templates/operator-roles.yaml | 145 + .../operator/templates/operator.yaml | 97 + .../helm_charts/operator/values.yaml | 59 + .../helm_charts/opsmanager/.helmignore | 23 + .../helm_charts/opsmanager/Chart.lock | 9 + .../helm_charts/opsmanager/Chart.yaml | 12 + production_notes/helm_charts/opsmanager/crds | 1 + .../templates/ops-manager-global-admin.yaml | 14 + .../opsmanager/templates/ops-manager.yaml | 65 + .../templates/opsmanager-roles.yaml | 52 + .../helm_charts/opsmanager/values.yaml | 34 + production_notes/helm_charts/ycsb/.helmignore | 22 + production_notes/helm_charts/ycsb/Chart.yaml | 4 + production_notes/helm_charts/ycsb/ca_tls.crt | 1 + .../helm_charts/ycsb/templates/job.yaml | 63 + .../ycsb/templates/workload.configmap.yaml | 12 + .../ycsb/templates/ycsb_secret.yaml | 9 + production_notes/helm_charts/ycsb/values.yaml | 23 + .../helm_charts/ycsb/workload-a.yaml | 24 + production_notes/monitoring/00-ns.yaml | 5 + production_notes/monitoring/02-role.yaml | 40 + production_notes/monitoring/03-pvc.yaml | 11 + production_notes/monitoring/04-cm.yaml | 67 + .../monitoring/05-deployment.yaml | 56 + production_notes/monitoring/06-svc-int.yaml | 19 + .../monitoring/07-grafana-pvc.yaml | 11 + .../monitoring/08-grafana-dep.yaml | 31 + .../monitoring/09-grafana-svc.yaml | 17 + production_notes/monitoring/10-prom-ext.yaml | 19 + production_notes/pkg/monitor/monitor.go | 133 + .../pkg/provisioner/provisioner.go | 69 + production_notes/pkg/s3/s3.go | 38 + production_notes/pkg/ycsb/ycsb.go | 76 + public/.github/ISSUE_TEMPLATE/bug_report.md | 46 + public/.github/ISSUE_TEMPLATE/config.yml | 8 + public/.github/PULL_REQUEST_TEMPLATE.md | 7 + .../workflows/release-multicluster-cli.yaml | 27 + public/LICENSE | 2 + public/README.md | 200 ++ public/crds.yaml | 2962 +++++++++++++++++ .../10.29.0.6830-1/ubi/Dockerfile | 33 + .../10.29.0.6830-1/ubuntu/Dockerfile | 36 + .../11.0.1.6929-1/ubi/Dockerfile | 44 + .../11.0.1.6929-1/ubuntu/Dockerfile | 47 + .../11.0.11.7036-1/ubi/Dockerfile | 46 + .../11.0.11.7036-1/ubuntu/Dockerfile | 47 + .../11.0.12.7051-1/ubi/Dockerfile | 46 + .../11.0.12.7051-1/ubuntu/Dockerfile | 47 + .../11.0.13.7055-1/ubi/Dockerfile | 46 + .../11.0.13.7055-1/ubuntu/Dockerfile | 47 + .../11.0.14.7064-1/ubi/Dockerfile | 46 + .../11.0.14.7064-1/ubuntu/Dockerfile | 47 + .../11.0.15.7073-1/ubi/Dockerfile | 46 + .../11.0.15.7073-1/ubuntu/Dockerfile | 47 + .../11.0.16.7080-1/ubi/Dockerfile | 46 + .../11.0.16.7080-1/ubuntu/Dockerfile | 47 + .../11.0.17.7084-1/ubi/Dockerfile | 45 + .../11.0.17.7084-1/ubuntu/Dockerfile | 47 + .../11.0.19.7094-1/ubi/Dockerfile | 45 + .../11.0.19.7094-1/ubuntu/Dockerfile | 47 + .../11.0.5.6963-1/ubi/Dockerfile | 44 + .../11.0.5.6963-1/ubuntu/Dockerfile | 47 + .../11.12.0.7388-1/ubi/Dockerfile | 46 + .../11.12.0.7388-1/ubuntu/Dockerfile | 47 + .../12.0.10.7591-1/ubi/Dockerfile | 45 + .../12.0.11.7606-1/ubi/Dockerfile | 45 + .../12.0.15.7646-1/ubi/Dockerfile | 45 + .../12.0.4.7554-1/ubi/Dockerfile | 46 + .../12.0.4.7554-1/ubuntu/Dockerfile | 47 + .../12.0.8.7575-1/ubi/Dockerfile | 46 + .../12.0.8.7575-1/ubuntu/Dockerfile | 47 + .../10.2.15.5958-1_4.2.11-ent/ubi/Dockerfile | 112 + .../ubuntu/Dockerfile | 103 + .../2.0.0/ubi/Dockerfile | 89 + .../2.0.0/ubuntu/Dockerfile | 80 + .../2.0.1/ubi/Dockerfile | 87 + .../2.0.1/ubuntu/Dockerfile | 78 + .../2.0.2/ubi/Dockerfile | 87 + .../2.0.2/ubuntu/Dockerfile | 77 + .../1.0.10/ubi/Dockerfile | 35 + .../1.0.10/ubuntu/Dockerfile | 31 + .../1.0.11/ubi/Dockerfile | 35 + .../1.0.11/ubuntu/Dockerfile | 31 + .../1.0.12/ubi/Dockerfile | 35 + .../1.0.12/ubuntu/Dockerfile | 31 + .../1.0.13/ubi/Dockerfile | 35 + .../1.0.13/ubuntu/Dockerfile | 31 + .../1.0.14/ubi/Dockerfile | 35 + .../1.0.14/ubuntu/Dockerfile | 31 + .../1.0.6/ubi/Dockerfile | 31 + .../1.0.6/ubuntu/Dockerfile | 30 + .../1.0.7/ubi/Dockerfile | 35 + .../1.0.7/ubuntu/Dockerfile | 31 + .../1.0.8/ubi/Dockerfile | 35 + .../1.0.8/ubuntu/Dockerfile | 31 + .../1.0.9/ubi/Dockerfile | 35 + .../1.0.9/ubuntu/Dockerfile | 31 + .../1.0.10/ubi/Dockerfile | 34 + .../1.0.10/ubuntu/Dockerfile | 30 + .../1.0.11/ubi/Dockerfile | 34 + .../1.0.11/ubuntu/Dockerfile | 30 + .../1.0.12/ubi/Dockerfile | 34 + .../1.0.12/ubuntu/Dockerfile | 30 + .../1.0.13/ubi/Dockerfile | 34 + .../1.0.13/ubuntu/Dockerfile | 30 + .../1.0.14/ubi/Dockerfile | 34 + .../1.0.14/ubuntu/Dockerfile | 30 + .../1.0.2/ubi/Dockerfile | 31 + .../1.0.2/ubuntu/Dockerfile | 30 + .../1.0.3/ubi/Dockerfile | 34 + .../1.0.3/ubuntu/Dockerfile | 30 + .../1.0.4/ubi/Dockerfile | 34 + .../1.0.4/ubuntu/Dockerfile | 30 + .../1.0.5/ubi/Dockerfile | 34 + .../1.0.5/ubuntu/Dockerfile | 30 + .../1.0.6/ubi/Dockerfile | 34 + .../1.0.6/ubuntu/Dockerfile | 30 + .../1.0.7/ubi/Dockerfile | 34 + .../1.0.7/ubuntu/Dockerfile | 30 + .../1.0.8/ubi/Dockerfile | 34 + .../1.0.8/ubuntu/Dockerfile | 30 + .../1.0.9/ubi/Dockerfile | 34 + .../1.0.9/ubuntu/Dockerfile | 30 + .../1.0.10/ubi/Dockerfile | 26 + .../1.0.10/ubuntu/Dockerfile | 24 + .../1.0.3/ubi/Dockerfile | 21 + .../1.0.3/ubuntu/Dockerfile | 21 + .../1.0.4/ubi/Dockerfile | 26 + .../1.0.4/ubuntu/Dockerfile | 24 + .../1.0.5/ubi/Dockerfile | 26 + .../1.0.5/ubuntu/Dockerfile | 24 + .../1.0.6/ubi/Dockerfile | 26 + .../1.0.6/ubuntu/Dockerfile | 24 + .../1.0.7/ubi/Dockerfile | 26 + .../1.0.7/ubuntu/Dockerfile | 24 + .../1.0.8/ubi/Dockerfile | 26 + .../1.0.8/ubuntu/Dockerfile | 24 + .../1.0.9/ubi/Dockerfile | 26 + .../1.0.9/ubuntu/Dockerfile | 24 + .../1.10.0/ubi/Dockerfile | 40 + .../1.10.0/ubuntu/Dockerfile | 42 + .../1.11.0/ubi/Dockerfile | 39 + .../1.11.0/ubuntu/Dockerfile | 41 + .../1.12.0/ubi/Dockerfile | 39 + .../1.12.0/ubuntu/Dockerfile | 41 + .../1.13.0/ubi/Dockerfile | 39 + .../1.13.0/ubuntu/Dockerfile | 41 + .../1.14.0/ubi/Dockerfile | 39 + .../1.14.0/ubuntu/Dockerfile | 41 + .../1.15.0/ubi/Dockerfile | 39 + .../1.15.0/ubuntu/Dockerfile | 41 + .../1.15.1/ubi/Dockerfile | 39 + .../1.15.1/ubuntu/Dockerfile | 41 + .../1.15.2/ubi/Dockerfile | 39 + .../1.15.2/ubuntu/Dockerfile | 41 + .../1.16.0/ubi/Dockerfile | 39 + .../1.16.0/ubuntu/Dockerfile | 41 + .../1.16.1/ubi/Dockerfile | 39 + .../1.16.1/ubuntu/Dockerfile | 41 + .../1.16.2/ubi/Dockerfile | 39 + .../1.16.2/ubuntu/Dockerfile | 41 + .../1.16.3/ubi/Dockerfile | 39 + .../1.16.3/ubuntu/Dockerfile | 41 + .../1.16.4/ubi/Dockerfile | 39 + .../1.16.4/ubuntu/Dockerfile | 41 + .../1.17.0/ubi/Dockerfile | 39 + .../1.17.0/ubuntu/Dockerfile | 41 + .../1.17.1/ubi/Dockerfile | 39 + .../1.17.1/ubuntu/Dockerfile | 41 + .../1.17.2/ubi/Dockerfile | 39 + .../1.17.2/ubuntu/Dockerfile | 41 + .../1.18.0/ubi/Dockerfile | 39 + .../1.18.0/ubuntu/Dockerfile | 41 + .../1.9.0/ubi/Dockerfile | 35 + .../1.9.0/ubuntu/Dockerfile | 40 + .../1.9.1/ubi/Dockerfile | 38 + .../1.9.1/ubuntu/Dockerfile | 40 + .../1.9.2/ubi/Dockerfile | 38 + .../1.9.2/ubuntu/Dockerfile | 40 + .../4.2.26/ubi/Dockerfile | 70 + .../4.2.26/ubuntu/Dockerfile | 79 + .../4.4.10/ubi/Dockerfile | 71 + .../4.4.10/ubuntu/Dockerfile | 79 + .../4.4.11/ubi/Dockerfile | 71 + .../4.4.11/ubuntu/Dockerfile | 79 + .../4.4.12/ubi/Dockerfile | 71 + .../4.4.12/ubuntu/Dockerfile | 79 + .../4.4.13/ubi/Dockerfile | 71 + .../4.4.13/ubuntu/Dockerfile | 79 + .../4.4.14/ubi/Dockerfile | 70 + .../4.4.14/ubuntu/Dockerfile | 79 + .../4.4.15/ubi/Dockerfile | 71 + .../4.4.15/ubuntu/Dockerfile | 79 + .../4.4.16/ubi/Dockerfile | 71 + .../4.4.16/ubuntu/Dockerfile | 79 + .../4.4.17/ubi/Dockerfile | 71 + .../4.4.17/ubuntu/Dockerfile | 79 + .../4.4.18/ubi/Dockerfile | 71 + .../4.4.18/ubuntu/Dockerfile | 79 + .../4.4.19/ubi/Dockerfile | 71 + .../4.4.19/ubuntu/Dockerfile | 79 + .../4.4.20/ubi/Dockerfile | 72 + .../4.4.20/ubuntu/Dockerfile | 80 + .../4.4.21/ubi/Dockerfile | 72 + .../4.4.21/ubuntu/Dockerfile | 80 + .../4.4.22/ubi/Dockerfile | 72 + .../4.4.22/ubuntu/Dockerfile | 80 + .../4.4.23/ubi/Dockerfile | 75 + .../4.4.23/ubuntu/Dockerfile | 83 + .../4.4.24/ubi/Dockerfile | 75 + .../4.4.24/ubuntu/Dockerfile | 83 + .../4.4.7/ubi/Dockerfile | 71 + .../4.4.7/ubuntu/Dockerfile | 79 + .../4.4.9/ubi/Dockerfile | 71 + .../4.4.9/ubuntu/Dockerfile | 79 + .../5.0.0/ubi/Dockerfile | 71 + .../5.0.0/ubuntu/Dockerfile | 79 + .../5.0.1/ubi/Dockerfile | 71 + .../5.0.1/ubuntu/Dockerfile | 79 + .../5.0.10/ubi/Dockerfile | 72 + .../5.0.10/ubuntu/Dockerfile | 80 + .../5.0.11/ubi/Dockerfile | 75 + .../5.0.11/ubuntu/Dockerfile | 83 + .../5.0.12/ubi/Dockerfile | 75 + .../5.0.12/ubuntu/Dockerfile | 83 + .../5.0.13/ubi/Dockerfile | 75 + .../5.0.13/ubuntu/Dockerfile | 83 + .../5.0.14/ubi/Dockerfile | 75 + .../5.0.14/ubuntu/Dockerfile | 83 + .../5.0.15/ubi/Dockerfile | 75 + .../5.0.15/ubuntu/Dockerfile | 83 + .../5.0.16/ubi/Dockerfile | 75 + .../5.0.16/ubuntu/Dockerfile | 83 + .../5.0.17/ubi/Dockerfile | 75 + .../5.0.17/ubuntu/Dockerfile | 83 + .../5.0.2/ubi/Dockerfile | 75 + .../5.0.2/ubuntu/Dockerfile | 83 + .../5.0.3/ubi/Dockerfile | 71 + .../5.0.3/ubuntu/Dockerfile | 79 + .../5.0.4/ubi/Dockerfile | 71 + .../5.0.4/ubuntu/Dockerfile | 79 + .../5.0.5/ubi/Dockerfile | 72 + .../5.0.5/ubuntu/Dockerfile | 80 + .../5.0.6/ubi/Dockerfile | 72 + .../5.0.6/ubuntu/Dockerfile | 80 + .../5.0.7/ubi/Dockerfile | 72 + .../5.0.7/ubuntu/Dockerfile | 80 + .../5.0.8/ubi/Dockerfile | 72 + .../5.0.8/ubuntu/Dockerfile | 80 + .../5.0.9/ubi/Dockerfile | 72 + .../5.0.9/ubuntu/Dockerfile | 80 + .../6.0.0/ubi/Dockerfile | 75 + .../6.0.0/ubuntu/Dockerfile | 83 + .../6.0.1/ubi/Dockerfile | 75 + .../6.0.1/ubuntu/Dockerfile | 83 + .../6.0.2/ubi/Dockerfile | 75 + .../6.0.2/ubuntu/Dockerfile | 83 + .../6.0.3/ubi/Dockerfile | 75 + .../6.0.3/ubuntu/Dockerfile | 83 + .../6.0.4/ubi/Dockerfile | 75 + .../6.0.4/ubuntu/Dockerfile | 83 + .../6.0.5/ubi/Dockerfile | 75 + .../6.0.5/ubuntu/Dockerfile | 83 + .../6.0.6/ubi/Dockerfile | 75 + .../6.0.6/ubuntu/Dockerfile | 83 + .../6.0.7/ubi/Dockerfile | 75 + .../6.0.7/ubuntu/Dockerfile | 83 + public/docs/assets/image--000.png | Bin 0 -> 564411 bytes public/docs/assets/image--002.png | Bin 0 -> 267945 bytes public/docs/assets/image--004.png | Bin 0 -> 753092 bytes public/docs/assets/image--008.png | Bin 0 -> 429946 bytes public/docs/assets/image--014.png | Bin 0 -> 199903 bytes public/docs/assets/image--030.png | Bin 0 -> 329398 bytes public/docs/assets/image--032.png | Bin 0 -> 397401 bytes public/docs/assets/image--034.png | Bin 0 -> 300412 bytes public/docs/openshift-marketplace.md | 149 + public/docs/upgrading-to-ops-manager-5.md | 125 + public/grafana/sample_dashboard.json | 1364 ++++++++ public/mongodb-enterprise-multi-cluster.yaml | 209 ++ public/mongodb-enterprise-openshift.yaml | 499 +++ public/mongodb-enterprise.yaml | 284 ++ .../multi_cluster_verify/sample-service.yaml | 85 + public/opa_examples/README.md | 54 + .../debugging/constraint_template.yaml | 17 + .../opa_examples/debugging/constraints.yaml | 11 + .../mongodb_allow_replicaset/constraints.yaml | 9 + .../mongodb_allow_replicaset.yaml | 25 + .../mongodb_allowed_versions/constraints.yaml | 9 + .../mongodb_allowed_versions.yaml | 28 + .../mongodb_strict_tls/constraints.yaml | 9 + .../mongodb_strict_tls.yaml | 36 + .../constraints.yaml | 9 + .../ops_manager_allowed_versions.yaml | 28 + .../constraints.yaml | 9 + .../ops_manager_replica_members.yaml | 37 + .../ops_manager_wizardless/constraints.yaml | 9 + .../ops_manager_wizardless_template.yaml | 24 + .../affinity/replica-set-affinity.yaml | 48 + .../affinity/sharded-cluster-affinity.yaml | 45 + .../mongodb/affinity/standalone-affinity.yaml | 37 + .../replica-set-agent-startup-options.yaml | 21 + ...sharded-cluster-agent-startup-options.yaml | 45 + .../standalone-agent-startup-options.yaml | 24 + .../replica-set/replica-set-ldap-user.yaml | 19 + .../ldap/replica-set/replica-set-ldap.yaml | 67 + .../sharded-cluster-ldap-user.yaml | 19 + .../sharded-cluster/sharded-cluster-ldap.yaml | 56 + .../replica-set-scram-password.yaml | 8 + .../replica-set/replica-set-scram-sha.yaml | 30 + .../replica-set/replica-set-scram-user.yaml | 22 + .../sharded-cluster-scram-password.yaml | 8 + .../sharded-cluster-scram-sha.yaml | 34 + .../sharded-cluster-scram-user.yaml | 19 + .../standalone/standalone-scram-password.yaml | 8 + .../standalone/standalone-scram-sha.yaml | 28 + .../standalone/standalone-scram-user.yaml | 18 + .../x509/replica-set/replica-set-x509.yaml | 39 + .../authentication/x509/replica-set/user.yaml | 13 + .../sharded-cluster/sharded-cluster-x509.yaml | 42 + .../x509/sharded-cluster/user.yaml | 13 + .../backup/replica-set-backup-disabled.yaml | 16 + .../mongodb/backup/replica-set-backup.yaml | 16 + .../replica-set-external.yaml | 36 + .../samples/mongodb/minimal/replica-set.yaml | 32 + .../mongodb/minimal/sharded-cluster.yaml | 68 + .../samples/mongodb/minimal/standalone.yaml | 40 + .../replica-set-mongod-options.yaml | 19 + .../sharded-cluster-mongod-options.yaml | 33 + .../replica-set-persistent-volumes.yaml | 60 + .../sharded-cluster-persistent-volumes.yaml | 75 + .../standalone-persistent-volumes.yaml | 51 + .../initcontainer-sysctl_config.yaml | 25 + .../replica-set-pod-template.yaml | 42 + .../sharded-cluster-pod-template.yaml | 72 + .../pod-template/standalone-pod-template.yaml | 17 + .../mongodb/podspec/replica-set-podspec.yaml | 84 + .../podspec/sharded-cluster-podspec.yaml | 103 + .../mongodb/podspec/standalone-podspec.yaml | 71 + public/samples/mongodb/project.yaml | 13 + .../mongodb/prometheus/replica-set.yaml | 45 + .../mongodb/prometheus/sharded-cluster.yaml | 83 + .../tls/replica-set/replica-set-tls.yaml | 37 + .../sharded-cluster/sharded-cluster-tls.yaml | 36 + .../tls/standalone/standalone-tls.yaml | 27 + .../replica-set-configure-storage.yaml | 70 + .../replica-set-sts-override.yaml | 68 + .../mongodb_multicluster/replica-set.yaml | 23 + .../multi-cluster-cli-gitops/README.md | 22 + .../argocd/application.yaml | 23 + .../argocd/project.yaml | 23 + .../resources/job.yaml | 36 + .../rbac/cluster_scoped_central_cluster.yaml | 99 + .../rbac/cluster_scoped_member_cluster.yaml | 113 + .../namespace_scoped_central_cluster.yaml | 94 + .../rbac/namespace_scoped_member_cluster.yaml | 150 + .../resources/replica-set.yaml | 23 + ...anager-appdb-agent-startup-parameters.yaml | 33 + .../ops-manager-appdb-custom-images.yaml | 24 + .../ops-manager/ops-manager-backup.yaml | 74 + .../ops-manager-disable-appdb-process.yaml | 20 + .../ops-manager/ops-manager-external.yaml | 35 + .../ops-manager-ignore-ui-setup.yaml | 27 + .../ops-manager/ops-manager-local-mode.yaml | 40 + .../ops-manager/ops-manager-non-root.yaml | 26 + .../ops-manager/ops-manager-pod-spec.yaml | 73 + .../ops-manager/ops-manager-remote-mode.yaml | 140 + .../ops-manager/ops-manager-scram.yaml | 23 + .../samples/ops-manager/ops-manager-tls.yaml | 38 + public/samples/ops-manager/ops-manager.yaml | 36 + public/support/certificate_rotation.sh | 84 + .../support/mdb_operator_diagnostic_data.sh | 337 ++ public/tools/multicluster/.gitignore | 6 + public/tools/multicluster/.goreleaser.yaml | 34 + public/tools/multicluster/Dockerfile | 10 + public/tools/multicluster/LICENSE | 202 ++ public/tools/multicluster/cmd/debug.go | 138 + public/tools/multicluster/cmd/multicluster.go | 17 + public/tools/multicluster/cmd/recover.go | 99 + public/tools/multicluster/cmd/root.go | 36 + public/tools/multicluster/cmd/setup.go | 94 + public/tools/multicluster/go.mod | 59 + public/tools/multicluster/go.sum | 657 ++++ .../install_istio_separate_network.sh | 188 ++ public/tools/multicluster/licenses.csv | 49 + public/tools/multicluster/main.go | 7 + .../tools/multicluster/pkg/common/common.go | 981 ++++++ .../multicluster/pkg/common/common_test.go | 887 +++++ .../pkg/common/kubeclientcontainer.go | 271 ++ .../multicluster/pkg/common/kubeconfig.go | 84 + public/tools/multicluster/pkg/common/utils.go | 21 + .../tools/multicluster/pkg/debug/anonymize.go | 30 + .../multicluster/pkg/debug/anonymize_test.go | 40 + .../multicluster/pkg/debug/collectors.go | 357 ++ .../multicluster/pkg/debug/collectors_test.go | 172 + public/tools/multicluster/pkg/debug/writer.go | 128 + .../multicluster/pkg/debug/writer_test.go | 72 + public/vault_policies/appdb-policy.hcl | 6 + public/vault_policies/database-policy.hcl | 6 + public/vault_policies/operator-policy.hcl | 6 + public/vault_policies/opsmanager-policy.hcl | 6 + release.json | 279 ++ requirements.txt | 14 + scripts/dev/apply_resources | 17 + scripts/dev/configure_docker_auth.sh | 50 + scripts/dev/configure_operator.sh | 44 + scripts/dev/delete_om_projects.sh | 33 + scripts/dev/deploy_operator.sh | 110 + scripts/dev/ensure_k8s.sh | 39 + scripts/dev/evg_host.sh | 162 + scripts/dev/install.sh | 62 + scripts/dev/interconnect_kind_clusters.sh | 74 + .../dev/kind_clusters_check_interconnect.sh | 163 + scripts/dev/launch_e2e.sh | 81 + scripts/dev/prepare_local_e2e_olm_run.sh | 17 + scripts/dev/prepare_local_e2e_run.sh | 51 + scripts/dev/print_automation_config | 13 + scripts/dev/print_contexts | 16 + scripts/dev/print_operator_env.sh | 65 + scripts/dev/read_context.sh | 21 + scripts/dev/recreate_e2e_kops.sh | 48 + scripts/dev/recreate_kind_cluster.sh | 9 + scripts/dev/recreate_kind_clusters.sh | 23 + scripts/dev/reset.sh | 100 + scripts/dev/samples/README.md | 38 + scripts/dev/samples/dev | 29 + scripts/dev/samples/kind | 68 + scripts/dev/samples/multi | 100 + scripts/dev/samples/multi-kind | 100 + scripts/dev/samples/openshift | 14 + scripts/dev/samples/quay | 13 + scripts/dev/set_env_context.sh | 63 + scripts/dev/setup_kind_cluster.sh | 133 + scripts/dev/status | 20 + scripts/dev/switch_context.sh | 31 + scripts/evergreen/add_evergreen_task.sh | 29 + .../build_multi_cluster_kubeconfig_creator.sh | 8 + scripts/evergreen/check_precommit.sh | 23 + scripts/evergreen/configure-docker-datadir.sh | 31 + .../multi-cluster-roles/Chart.yaml | 2 + .../templates/mongodb-enterprise-tests.yaml | 25 + .../multi-cluster-roles/values.yaml | 5 + .../deployments/ops-manager-vanilla.yaml | 35 + .../evergreen/deployments/test-app/Chart.yaml | 2 + .../templates/mongodb-enterprise-tests.yaml | 146 + .../deployments/test-app/values.yaml | 31 + .../deployments/values-ops-manager.yaml | 6 + scripts/evergreen/e2e/configure_operator.sh | 53 + .../evergreen/e2e/dump_diagnostic_information | 243 ++ scripts/evergreen/e2e/e2e.sh | 111 + scripts/evergreen/e2e/fetch_om_information | 47 + scripts/evergreen/e2e/lib | 37 + scripts/evergreen/e2e/setup_cloud_qa.py | 383 +++ scripts/evergreen/e2e/single_e2e.sh | 191 ++ scripts/evergreen/e2e/teardown.sh | 22 + scripts/evergreen/flakiness-report.py | 75 + .../generate_evergreen_expansions.sh | 17 + scripts/evergreen/lint_code.sh | 67 + scripts/evergreen/operator-sdk/install-olm.sh | 5 + .../prepare-openshift-bundles-for-e2e.sh | 147 + .../operator-sdk/prepare-openshift-bundles.sh | 52 + scripts/evergreen/prepare_aws.sh | 29 + scripts/evergreen/prepare_test_env.sh | 100 + .../evergreen/release/helm_files_handler.py | 53 + .../release/update_helm_values_files.py | 64 + scripts/evergreen/release/update_release.py | 109 + scripts/evergreen/release_blocker | 10 + scripts/evergreen/requirements.txt | 6 + scripts/evergreen/retry-evergreen.sh | 51 + scripts/evergreen/setup_aws.sh | 26 + scripts/evergreen/setup_jq.sh | 12 + scripts/evergreen/setup_kind.sh | 27 + scripts/evergreen/setup_kubectl.sh | 20 + .../evergreen/setup_kubernetes_environment.sh | 78 + scripts/evergreen/setup_minikube.sh | 17 + scripts/evergreen/setup_preflight.sh | 15 + .../setup_prepare_openshift_bundles.sh | 43 + scripts/evergreen/setup_shellcheck.sh | 13 + scripts/evergreen/setup_yq.sh | 11 + .../should_prepare_openshift_bundles.sh | 40 + scripts/evergreen/tag_push_docker_image.sh | 36 + .../teardown_kubernetes_environment.sh | 17 + scripts/evergreen/unit-tests.sh | 5 + scripts/evergreen/update_licenses.sh | 12 + scripts/evergreen/update_licenses.tpl | 3 + scripts/funcs/checks | 35 + scripts/funcs/errors | 12 + scripts/funcs/install | 18 + scripts/funcs/kubernetes | 263 ++ scripts/funcs/multicluster | 270 ++ scripts/funcs/operator_deployment | 65 + scripts/funcs/printing | 20 + scripts/preflight_images.py | 208 ++ scripts/update_dockerfiles_in_s3.py | 48 + scripts/update_supported_dockerfiles.py | 105 + tools.go | 16 + 1383 files changed, 152028 insertions(+) create mode 100644 .dockerignore create mode 100644 .evergreen-periodic-builds.yaml create mode 100644 .evergreen.yml create mode 100755 .githooks/pre-commit create mode 100644 .github/CODEOWNERS create mode 100644 .github/PULL_REQUEST_TEMPLATE/internal_change.md create mode 100644 .github/PULL_REQUEST_TEMPLATE/release.md create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 Makefile create mode 100644 PROJECT create mode 100644 README.md create mode 100644 RELEASE_NOTES.md create mode 100644 api/v1/addtoscheme_mongodb_v1.go create mode 100644 api/v1/apis.go create mode 100644 api/v1/customresource_readwriter.go create mode 100644 api/v1/doc.go create mode 100644 api/v1/kmip.go create mode 100644 api/v1/mdb/doc.go create mode 100644 api/v1/mdb/groupversion_info.go create mode 100644 api/v1/mdb/mongodb_roles_validation.go create mode 100644 api/v1/mdb/mongodb_types.go create mode 100644 api/v1/mdb/mongodb_types_test.go create mode 100644 api/v1/mdb/mongodb_validation.go create mode 100644 api/v1/mdb/mongodb_validation_test.go create mode 100644 api/v1/mdb/mongodbbuilder.go create mode 100644 api/v1/mdb/mongodconfig.go create mode 100644 api/v1/mdb/mongodconfig_test.go create mode 100644 api/v1/mdb/podspecbuilder.go create mode 100644 api/v1/mdb/shardedcluster.go create mode 100644 api/v1/mdb/wrap.go create mode 100644 api/v1/mdb/zz_generated.deepcopy.go create mode 100644 api/v1/mdbmulti/doc.go create mode 100644 api/v1/mdbmulti/groupversion_info.go create mode 100644 api/v1/mdbmulti/mongodb_multi_types.go create mode 100644 api/v1/mdbmulti/mongodbmulti_validation.go create mode 100644 api/v1/mdbmulti/mongodbmulti_validation_test.go create mode 100644 api/v1/mdbmulti/mongodbmultibuilder.go create mode 100644 api/v1/mdbmulti/zz_generated.deepcopy.go create mode 100644 api/v1/om/appdb_types.go create mode 100644 api/v1/om/doc.go create mode 100644 api/v1/om/groupversion_info.go create mode 100644 api/v1/om/opsmanager_types.go create mode 100644 api/v1/om/opsmanager_types_test.go create mode 100644 api/v1/om/opsmanager_validation.go create mode 100644 api/v1/om/opsmanager_validation_test.go create mode 100644 api/v1/om/opsmanagerbuilder.go create mode 100644 api/v1/om/zz_generated.deepcopy.go create mode 100644 api/v1/register.go create mode 100644 api/v1/status/doc.go create mode 100644 api/v1/status/option.go create mode 100644 api/v1/status/part.go create mode 100644 api/v1/status/phase.go create mode 100644 api/v1/status/resourcenotready.go create mode 100644 api/v1/status/scaling_status.go create mode 100644 api/v1/status/status.go create mode 100644 api/v1/status/warnings.go create mode 100644 api/v1/status/zz_generated.deepcopy.go create mode 100644 api/v1/user/doc.go create mode 100644 api/v1/user/groupversion_info.go create mode 100644 api/v1/user/mongodbuser_types.go create mode 100644 api/v1/user/mongodbuser_types_test.go create mode 100644 api/v1/user/zz_generated.deepcopy.go create mode 100644 api/v1/validation.go create mode 100644 api/v1/zz_generated.deepcopy.go create mode 100644 config/crd/bases/mongodb.com_mongodb.yaml create mode 100644 config/crd/bases/mongodb.com_mongodbmulticluster.yaml create mode 100644 config/crd/bases/mongodb.com_mongodbusers.yaml create mode 100644 config/crd/bases/mongodb.com_opsmanagers.yaml create mode 100644 config/crd/kustomization.yaml create mode 100644 config/crd/kustomizeconfig.yaml create mode 100644 config/default/kustomization.yaml create mode 100644 config/manager/kustomization.yaml create mode 100644 config/manifests/bases/mongodb-enterprise.clusterserviceversion.yaml create mode 100644 config/manifests/kustomization.yaml create mode 100644 config/rbac/appdb_role.yaml create mode 100644 config/rbac/appdb_role_binding.yaml create mode 100644 config/rbac/appdb_sa.yaml create mode 100644 config/rbac/database_sa.yaml create mode 100644 config/rbac/kustomization.yaml create mode 100644 config/rbac/mongodb_editor_role.yaml create mode 100644 config/rbac/mongodb_viewer_role.yaml create mode 100644 config/rbac/mongodbopsmanager_editor_role.yaml create mode 100644 config/rbac/mongodbopsmanager_viewer_role.yaml create mode 100644 config/rbac/mongodbusers_editor_role.yaml create mode 100644 config/rbac/mongodbusers_viewer_role.yaml create mode 100644 config/rbac/om_sa.yaml create mode 100644 config/rbac/role.yaml create mode 100644 config/rbac/role_binding.yaml create mode 100644 config/samples/kustomization.yaml create mode 100644 config/samples/mongodb-multi.yaml create mode 100644 config/samples/mongodb-om.yaml create mode 100644 config/samples/mongodb-replicaset.yaml create mode 100644 config/samples/mongodb-sharded.yaml create mode 100644 config/samples/mongodb-user.yaml create mode 100644 config/scorecard/bases/config.yaml create mode 100644 config/scorecard/kustomization.yaml create mode 100644 config/scorecard/patches/basic.config.yaml create mode 100644 config/scorecard/patches/olm.config.yaml create mode 100644 controllers/controller.go create mode 100644 controllers/controller_test.go create mode 100644 controllers/om/agent.go create mode 100644 controllers/om/api/admin.go create mode 100644 controllers/om/api/digest.go create mode 100644 controllers/om/api/http.go create mode 100644 controllers/om/api/http_test.go create mode 100644 controllers/om/api/initializer.go create mode 100644 controllers/om/api/initializer_test.go create mode 100644 controllers/om/api/mockedomadmin.go create mode 100644 controllers/om/apierror/api_error.go create mode 100644 controllers/om/appdb_process.go create mode 100644 controllers/om/appdb_process_test.go create mode 100644 controllers/om/automation_config.go create mode 100644 controllers/om/automation_config_test.go create mode 100644 controllers/om/automation_status.go create mode 100644 controllers/om/backup/backup.go create mode 100644 controllers/om/backup/backup_config.go create mode 100644 controllers/om/backup/daemonconfig.go create mode 100644 controllers/om/backup/datastoreconfig.go create mode 100644 controllers/om/backup/group_config.go create mode 100644 controllers/om/backup/mongodbresource_backup.go create mode 100644 controllers/om/backup/mongodbresource_backup_test.go create mode 100644 controllers/om/backup/s3config.go create mode 100644 controllers/om/backup/snapshot_schedule.go create mode 100644 controllers/om/backup/snapshot_schedule_test.go create mode 100644 controllers/om/backup_agent_config.go create mode 100644 controllers/om/backup_agent_test.go create mode 100644 controllers/om/backup_test.go create mode 100644 controllers/om/deployment.go create mode 100644 controllers/om/deployment/om_deployment.go create mode 100644 controllers/om/deployment/om_deployment_test.go create mode 100644 controllers/om/deployment/testing_utils.go create mode 100644 controllers/om/deployment_test.go create mode 100644 controllers/om/depshardedcluster_test.go create mode 100644 controllers/om/fullreplicaset.go create mode 100644 controllers/om/fullreplicaset_test.go create mode 100644 controllers/om/group.go create mode 100644 controllers/om/host/hosts.go create mode 100644 controllers/om/host/monitoring.go create mode 100644 controllers/om/mockedomclient.go create mode 100644 controllers/om/monitoring_agent_config.go create mode 100644 controllers/om/monitoring_agent_test.go create mode 100644 controllers/om/omclient.go create mode 100644 controllers/om/omclient_test.go create mode 100644 controllers/om/ompaginator.go create mode 100644 controllers/om/ompaginator_test.go create mode 100644 controllers/om/organization.go create mode 100644 controllers/om/process.go create mode 100644 controllers/om/process/om_process.go create mode 100644 controllers/om/process_test.go create mode 100644 controllers/om/replicaset.go create mode 100644 controllers/om/replicaset/om_replicaset.go create mode 100644 controllers/om/replicaset/om_replicaset_test.go create mode 100644 controllers/om/replicaset_test.go create mode 100644 controllers/om/shardedcluster.go create mode 100644 controllers/om/test_utils.go create mode 100644 controllers/om/testdata/automation_config.json create mode 100644 controllers/om/testdata/backup_config.json create mode 100644 controllers/om/testdata/deployment_tls.json create mode 100644 controllers/om/testdata/monitoring_config.json create mode 100644 controllers/operator/agents/agents.go create mode 100644 controllers/operator/agents/upgrade.go create mode 100644 controllers/operator/appdbreplicaset_controller.go create mode 100644 controllers/operator/appdbreplicaset_controller_test.go create mode 100644 controllers/operator/authentication/authentication.go create mode 100644 controllers/operator/authentication/configure_authentication_test.go create mode 100644 controllers/operator/authentication/ldap.go create mode 100644 controllers/operator/authentication/ldap_test.go create mode 100644 controllers/operator/authentication/pkix.go create mode 100644 controllers/operator/authentication/scramsha.go create mode 100644 controllers/operator/authentication/scramsha_credentials.go create mode 100644 controllers/operator/authentication/scramsha_credentials_test.go create mode 100644 controllers/operator/authentication/scramsha_test.go create mode 100644 controllers/operator/authentication/x509.go create mode 100644 controllers/operator/authentication/x509_test.go create mode 100644 controllers/operator/authentication_test.go create mode 100644 controllers/operator/automationconfig/automationconfig.go create mode 100644 controllers/operator/automationconfig/automationconfig_test.go create mode 100644 controllers/operator/backup_snapshot_schedule_test.go create mode 100644 controllers/operator/certs/cert_configurations.go create mode 100644 controllers/operator/certs/certificate_test.go create mode 100644 controllers/operator/certs/certificates.go create mode 100644 controllers/operator/common_controller.go create mode 100644 controllers/operator/common_controller_test.go create mode 100644 controllers/operator/connection/opsmanager_connection.go create mode 100644 controllers/operator/connectionstring/connectionstring.go create mode 100644 controllers/operator/construct/appdb_construction.go create mode 100644 controllers/operator/construct/appdb_construction_test.go create mode 100644 controllers/operator/construct/backup_construction.go create mode 100644 controllers/operator/construct/backup_construction_test.go create mode 100644 controllers/operator/construct/construction_test.go create mode 100644 controllers/operator/construct/database_construction.go create mode 100644 controllers/operator/construct/database_construction_test.go create mode 100644 controllers/operator/construct/database_volumes.go create mode 100644 controllers/operator/construct/jvm.go create mode 100644 controllers/operator/construct/multicluster/multicluster_replicaset.go create mode 100644 controllers/operator/construct/multicluster/multicluster_replicaset_test.go create mode 100644 controllers/operator/construct/opsmanager_construction.go create mode 100644 controllers/operator/construct/opsmanager_construction_common.go create mode 100644 controllers/operator/construct/opsmanager_construction_test.go create mode 100644 controllers/operator/construct/pvc.go create mode 100644 controllers/operator/construct/resourcerequirements.go create mode 100644 controllers/operator/construct/resourcerequirements_test.go create mode 100644 controllers/operator/construct/testing_utils.go create mode 100644 controllers/operator/controlledfeature/controlled_feature.go create mode 100644 controllers/operator/controlledfeature/controlled_feature_test.go create mode 100644 controllers/operator/controlledfeature/feature_by_mdb.go create mode 100644 controllers/operator/controlledfeature/feature_by_mdb_test.go create mode 100644 controllers/operator/create/create.go create mode 100644 controllers/operator/create/create_test.go create mode 100644 controllers/operator/database_statefulset_options.go create mode 100644 controllers/operator/inspect/statefulset_inspector.go create mode 100644 controllers/operator/inspect/statefulset_inspector_test.go create mode 100644 controllers/operator/ldap/ldap_types.go create mode 100644 controllers/operator/mock/mockedkubeclient.go create mode 100644 controllers/operator/mock/test_fixtures.go create mode 100644 controllers/operator/mongodbmultireplicaset_controller.go create mode 100644 controllers/operator/mongodbmultireplicaset_controller_test.go create mode 100644 controllers/operator/mongodbopsmanager_controller.go create mode 100644 controllers/operator/mongodbopsmanager_controller_test.go create mode 100644 controllers/operator/mongodbopsmanager_event_handler.go create mode 100644 controllers/operator/mongodbreplicaset_controller.go create mode 100644 controllers/operator/mongodbreplicaset_controller_test.go create mode 100644 controllers/operator/mongodbresource_event_handler.go create mode 100644 controllers/operator/mongodbshardedcluster_controller.go create mode 100644 controllers/operator/mongodbshardedcluster_controller_test.go create mode 100644 controllers/operator/mongodbstandalone_controller.go create mode 100644 controllers/operator/mongodbstandalone_controller_test.go create mode 100644 controllers/operator/mongodbuser_controller.go create mode 100644 controllers/operator/mongodbuser_controller_test.go create mode 100644 controllers/operator/mongodbuser_eventhandler.go create mode 100644 controllers/operator/namespace_watched.go create mode 100644 controllers/operator/namespace_watched_test.go create mode 100644 controllers/operator/operator_configuration.go create mode 100644 controllers/operator/pem/pem_collection.go create mode 100644 controllers/operator/pem/pem_collection_test.go create mode 100644 controllers/operator/pem/secret.go create mode 100644 controllers/operator/pem_test.go create mode 100644 controllers/operator/project/credentials.go create mode 100644 controllers/operator/project/project.go create mode 100644 controllers/operator/project/projectconfig.go create mode 100644 controllers/operator/project/projectconfig_test.go create mode 100644 controllers/operator/secrets/secrets.go create mode 100644 controllers/operator/testdata/certificates/cert_auto create mode 100644 controllers/operator/testdata/certificates/cert_rfc2253 create mode 100644 controllers/operator/testdata/certificates/certificate_then_key create mode 100644 controllers/operator/testdata/certificates/just_certificate create mode 100644 controllers/operator/testdata/certificates/just_key create mode 100644 controllers/operator/testdata/certificates/key_then_certificate create mode 100644 controllers/operator/testdata/version_manifest.json create mode 100644 controllers/operator/watch/config_change_handler.go create mode 100644 controllers/operator/watch/config_change_handler_test.go create mode 100644 controllers/operator/watch/predicates.go create mode 100644 controllers/operator/watch/predicates_test.go create mode 100644 controllers/operator/watch/resource_watcher.go create mode 100644 controllers/operator/workflow/disabled.go create mode 100644 controllers/operator/workflow/failed.go create mode 100644 controllers/operator/workflow/invalid.go create mode 100644 controllers/operator/workflow/ok.go create mode 100644 controllers/operator/workflow/pending.go create mode 100644 controllers/operator/workflow/reconciling.go create mode 100644 controllers/operator/workflow/status.go create mode 100644 controllers/operator/workflow/status_test.go create mode 100644 controllers/operator/workflow/unsupported.go create mode 100644 controllers/operator/workflow/workflow.go create mode 100644 deploy/crd-and-csv-generation.md create mode 100644 deploy/crds/samples/ops-manager.yaml create mode 100644 deploy/crds/samples/replica-set-scram-user.yaml create mode 100644 deploy/crds/samples/replica-set.yaml create mode 160000 deploy/helm-charts create mode 100644 deploy/redhat_connect_zip_files/mongodb-enterprise.v1.7.0.zip create mode 100644 deploy/redhat_connect_zip_files/mongodb-enterprise.v1.7.1.zip create mode 100644 deploy/redhat_connect_zip_files/mongodb-enterprise.v1.8.0.zip create mode 100644 docker/Dockerfile create mode 100644 docker/cluster-cleaner/Chart.yaml create mode 100644 docker/cluster-cleaner/Dockerfile create mode 100644 docker/cluster-cleaner/Makefile create mode 100644 docker/cluster-cleaner/readme.md create mode 100755 docker/cluster-cleaner/scripts/clean-cluster-roles-and-bindings.sh create mode 100755 docker/cluster-cleaner/scripts/clean-failed-namespaces.sh create mode 100755 docker/cluster-cleaner/scripts/clean-ops-manager.sh create mode 100755 docker/cluster-cleaner/scripts/construction-site.sh create mode 100755 docker/cluster-cleaner/scripts/create-cluster-ca-as-configmap.sh create mode 100755 docker/cluster-cleaner/scripts/delete-old-builder-pods.sh create mode 100755 docker/cluster-cleaner/scripts/is_older_than.py create mode 100644 docker/cluster-cleaner/templates/job.yaml create mode 100644 docker/cluster-cleaner/templates/ops_manager_cleaner_job.yaml create mode 100644 docker/mongodb-enterprise-appdb-database/4.0/ubi/Dockerfile create mode 100644 docker/mongodb-enterprise-appdb-database/4.0/ubi/mongodb-org-4.0.repo create mode 100644 docker/mongodb-enterprise-appdb-database/4.0/ubuntu/Dockerfile create mode 100644 docker/mongodb-enterprise-appdb-database/4.2/ubi/Dockerfile create mode 100644 docker/mongodb-enterprise-appdb-database/4.2/ubi/mongodb-org-4.2.repo create mode 100644 docker/mongodb-enterprise-appdb-database/4.2/ubuntu/Dockerfile create mode 100644 docker/mongodb-enterprise-appdb-database/4.4/ubi/Dockerfile create mode 100644 docker/mongodb-enterprise-appdb-database/4.4/ubi/mongodb-org-4.4.repo create mode 100644 docker/mongodb-enterprise-appdb-database/4.4/ubuntu/Dockerfile create mode 100644 docker/mongodb-enterprise-appdb-database/5.0/ubi/Dockerfile create mode 100644 docker/mongodb-enterprise-appdb-database/5.0/ubi/mongodb-org-5.0.repo create mode 100644 docker/mongodb-enterprise-appdb-database/5.0/ubuntu/Dockerfile create mode 100644 docker/mongodb-enterprise-appdb-database/README.md create mode 100755 docker/mongodb-enterprise-appdb-database/build_and_push_appdb_database_images.sh create mode 100644 docker/mongodb-enterprise-appdb-database/docker-entrypoint.sh create mode 100644 docker/mongodb-enterprise-appdb-database/licenses/LICENSE create mode 100644 docker/mongodb-enterprise-database/Dockerfile.builder create mode 100644 docker/mongodb-enterprise-database/Dockerfile.dcar create mode 100644 docker/mongodb-enterprise-database/Dockerfile.template create mode 100644 docker/mongodb-enterprise-database/Dockerfile.ubi create mode 100644 docker/mongodb-enterprise-database/LICENSE create mode 100644 docker/mongodb-enterprise-database/README.md create mode 100644 docker/mongodb-enterprise-init-database/Dockerfile.builder create mode 100644 docker/mongodb-enterprise-init-database/Dockerfile.dcar create mode 100644 docker/mongodb-enterprise-init-database/Dockerfile.template create mode 100644 docker/mongodb-enterprise-init-database/Dockerfile.ubi_minimal create mode 100644 docker/mongodb-enterprise-init-database/content/LICENSE create mode 100755 docker/mongodb-enterprise-init-database/content/agent-launcher-lib.sh create mode 100755 docker/mongodb-enterprise-init-database/content/agent-launcher.sh create mode 100755 docker/mongodb-enterprise-init-database/content/probe.sh create mode 100644 docker/mongodb-enterprise-init-ops-manager/Dockerfile.builder create mode 100644 docker/mongodb-enterprise-init-ops-manager/Dockerfile.dcar create mode 100644 docker/mongodb-enterprise-init-ops-manager/Dockerfile.template create mode 100644 docker/mongodb-enterprise-init-ops-manager/Dockerfile.ubi_minimal create mode 100644 docker/mongodb-enterprise-init-ops-manager/LICENSE create mode 100644 docker/mongodb-enterprise-init-ops-manager/backupdaemon_readinessprobe/backupdaemon_readiness.go create mode 100644 docker/mongodb-enterprise-init-ops-manager/backupdaemon_readinessprobe/backupdaemon_readiness_test.go create mode 100644 docker/mongodb-enterprise-init-ops-manager/go.mod create mode 100644 docker/mongodb-enterprise-init-ops-manager/go.sum create mode 100755 docker/mongodb-enterprise-init-ops-manager/mmsconfiguration/edit_mms_configuration.go create mode 100755 docker/mongodb-enterprise-init-ops-manager/mmsconfiguration/edit_mms_configuration_test.go create mode 100755 docker/mongodb-enterprise-init-ops-manager/scripts/backup-daemon-liveness-probe.sh create mode 100755 docker/mongodb-enterprise-init-ops-manager/scripts/docker-entry-point.sh create mode 100644 docker/mongodb-enterprise-operator/Dockerfile.builder create mode 100644 docker/mongodb-enterprise-operator/Dockerfile.dcar create mode 100644 docker/mongodb-enterprise-operator/Dockerfile.template create mode 100644 docker/mongodb-enterprise-operator/Dockerfile.ubi create mode 100644 docker/mongodb-enterprise-operator/LICENSE create mode 100644 docker/mongodb-enterprise-operator/README.md create mode 100644 docker/mongodb-enterprise-operator/content/.keep create mode 100644 docker/mongodb-enterprise-operator/licenses/Apache-2.0/LICENSE create mode 100644 docker/mongodb-enterprise-operator/licenses/Apache-2.0/README create mode 100644 docker/mongodb-enterprise-operator/licenses/BSD-2-Clause/LICENSE create mode 100644 docker/mongodb-enterprise-operator/licenses/BSD-2-Clause/README create mode 100644 docker/mongodb-enterprise-operator/licenses/BSD-3-Clause/LICENSE create mode 100644 docker/mongodb-enterprise-operator/licenses/BSD-3-Clause/README create mode 100644 docker/mongodb-enterprise-operator/licenses/ISC/LICENSE create mode 100644 docker/mongodb-enterprise-operator/licenses/ISC/README create mode 100644 docker/mongodb-enterprise-operator/licenses/MIT/LICENSE create mode 100644 docker/mongodb-enterprise-operator/licenses/MIT/README create mode 100644 docker/mongodb-enterprise-operator/licenses/MPL-2.0/LICENSE create mode 100644 docker/mongodb-enterprise-operator/licenses/MPL-2.0/README create mode 100644 docker/mongodb-enterprise-operator/licenses/THIRD-PARTY-NOTICES create mode 100644 docker/mongodb-enterprise-ops-manager/Dockerfile.builder create mode 100644 docker/mongodb-enterprise-ops-manager/Dockerfile.dcar create mode 100644 docker/mongodb-enterprise-ops-manager/Dockerfile.template create mode 100644 docker/mongodb-enterprise-ops-manager/Dockerfile.ubi create mode 100644 docker/mongodb-enterprise-ops-manager/LICENSE create mode 100644 docker/mongodb-enterprise-tests/.dockerignore create mode 100644 docker/mongodb-enterprise-tests/.flake8 create mode 100644 docker/mongodb-enterprise-tests/.pylintrc create mode 100644 docker/mongodb-enterprise-tests/Dockerfile create mode 100644 docker/mongodb-enterprise-tests/README.md create mode 100644 docker/mongodb-enterprise-tests/kubetester/__init__.py create mode 100644 docker/mongodb-enterprise-tests/kubetester/automation_config_tester.py create mode 100644 docker/mongodb-enterprise-tests/kubetester/awss3client.py create mode 100644 docker/mongodb-enterprise-tests/kubetester/certs.py create mode 100644 docker/mongodb-enterprise-tests/kubetester/create_or_replace_from_yaml.py create mode 100644 docker/mongodb-enterprise-tests/kubetester/crypto.py create mode 100644 docker/mongodb-enterprise-tests/kubetester/custom_podspec.py create mode 100644 docker/mongodb-enterprise-tests/kubetester/git.py create mode 100644 docker/mongodb-enterprise-tests/kubetester/helm.py create mode 100644 docker/mongodb-enterprise-tests/kubetester/http.py create mode 100644 docker/mongodb-enterprise-tests/kubetester/kmip.py create mode 100644 docker/mongodb-enterprise-tests/kubetester/kubetester.py create mode 100644 docker/mongodb-enterprise-tests/kubetester/ldap.py create mode 100644 docker/mongodb-enterprise-tests/kubetester/mongodb.py create mode 100644 docker/mongodb-enterprise-tests/kubetester/mongodb_multi.py create mode 100644 docker/mongodb-enterprise-tests/kubetester/mongodb_user.py create mode 100644 docker/mongodb-enterprise-tests/kubetester/mongotester.py create mode 100644 docker/mongodb-enterprise-tests/kubetester/om_queryable_backups.py create mode 100644 docker/mongodb-enterprise-tests/kubetester/omtester.py create mode 100644 docker/mongodb-enterprise-tests/kubetester/operator.py create mode 100644 docker/mongodb-enterprise-tests/kubetester/opsmanager.py create mode 100644 docker/mongodb-enterprise-tests/kubetester/security_context.py create mode 100644 docker/mongodb-enterprise-tests/kubetester/test_identifiers.py create mode 100644 docker/mongodb-enterprise-tests/kubetester/vault.py create mode 100644 docker/mongodb-enterprise-tests/pyproject.toml create mode 100644 docker/mongodb-enterprise-tests/pytest.ini create mode 100644 docker/mongodb-enterprise-tests/requirements-dev.txt create mode 100644 docker/mongodb-enterprise-tests/requirements.txt create mode 100644 docker/mongodb-enterprise-tests/results/myreport.xml create mode 100644 docker/mongodb-enterprise-tests/tests/__init__.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/__init__.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/conftest.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/fixtures/ldap/ldap-agent-auth.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/fixtures/ldap/ldap-replica-set-roles.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/fixtures/ldap/ldap-replica-set.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/fixtures/ldap/ldap-sharded-cluster-user.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/fixtures/ldap/ldap-sharded-cluster.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/fixtures/ldap/ldap-user.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/fixtures/replica-set-basic.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/fixtures/replica-set-explicit-scram-sha-1.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/fixtures/replica-set-scram-sha-256-x509-internal-cluster.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/fixtures/replica-set-scram-sha-256.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/fixtures/replica-set-scram.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/fixtures/replica-set-tls-scram-sha-256.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/fixtures/replica-set-x509-to-scram-256.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/fixtures/scram-sha-user.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/fixtures/sharded-cluster-explicit-scram-sha-1.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/fixtures/sharded-cluster-scram-sha-1.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/fixtures/sharded-cluster-scram-sha-256-x509-internal-cluster.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/fixtures/sharded-cluster-scram-sha-256.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/fixtures/sharded-cluster-tls-scram-sha-256.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/fixtures/sharded-cluster-x509-internal-cluster-auth-transition.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/fixtures/sharded-cluster-x509-to-scram-256.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/replica_set_agent_ldap.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/replica_set_custom_roles.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/replica_set_feature_controls.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/replica_set_ignore_unkown_users.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/replica_set_ldap.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/replica_set_ldap_agent_client_certs.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/replica_set_ldap_group_dn.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/replica_set_ldap_group_dn_with_x509_agent.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/replica_set_ldap_tls.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/replica_set_ldap_user_to_dn_mapping.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/replica_set_scram_sha_1_connectivity.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/replica_set_scram_sha_256_connectivity.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/replica_set_scram_sha_256_user_first.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/replica_set_scram_sha_and_x509.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/replica_set_scram_sha_upgrade.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/replica_set_scram_x509_ic_manual_certs.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/replica_set_scram_x509_internal_cluster.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/replica_set_update_roles_no_privileges.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/replica_set_x509_to_scram_transition.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/sha1_connectivity_tests.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_ldap.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_scram_sha_1_connectivity.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_scram_sha_256_connectivity.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_scram_sha_and_x509.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_scram_sha_upgrade.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_scram_x509_ic_manual_certs.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_scram_x509_internal_cluster.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_x509_internal_cluster_transition.py create mode 100644 docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_x509_to_scram_transition.py create mode 100644 docker/mongodb-enterprise-tests/tests/clusterwideoperator/__init__.py create mode 100644 docker/mongodb-enterprise-tests/tests/clusterwideoperator/conftest.py create mode 100644 docker/mongodb-enterprise-tests/tests/clusterwideoperator/om_multiple.py create mode 100644 docker/mongodb-enterprise-tests/tests/conftest.py create mode 100644 docker/mongodb-enterprise-tests/tests/docs/MINIO.md create mode 100644 docker/mongodb-enterprise-tests/tests/docs/img.png create mode 100644 docker/mongodb-enterprise-tests/tests/docs/tls.crt create mode 100644 docker/mongodb-enterprise-tests/tests/docs/tls.key create mode 100644 docker/mongodb-enterprise-tests/tests/mixed/__init__.py create mode 100644 docker/mongodb-enterprise-tests/tests/mixed/all_mongodb_resources_parallel_test.py create mode 100644 docker/mongodb-enterprise-tests/tests/mixed/conftest.py create mode 100644 docker/mongodb-enterprise-tests/tests/mixed/crd_validation.py create mode 100644 docker/mongodb-enterprise-tests/tests/mixed/failures_on_multi_clusters.py create mode 100644 docker/mongodb-enterprise-tests/tests/mixed/fixtures/sample-mdb-object-to-test.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/__init__.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/conftest.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-multi-central-sts-override.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-multi-cluster.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-multi-dr.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-multi-split-horizon.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-multi-sts-override.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-multi.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-user.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-x509-user.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/fixtures/split-horizon-node-port.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/fixtures/split-horizon-node-ports/split-horizon-node-port.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/manual_multi_cluster_tls_no_mesh_2_clusters_eks_gke.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_2_cluster_clusterwide_replicaset.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_2_cluster_replicaset.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_agent_flags.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_automated_disaster_recovery.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_backup_restore.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_backup_restore_no_mesh.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_cli_recover.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_clusterwide.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_dr_connect.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_enable_tls.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_ldap.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_ldap_custom_roles.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_recover_clusterwide.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_recover_network_partition.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_replica_set.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_replica_set_deletion.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_replica_set_ignore_unknown_users.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_replica_set_member_options.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_replica_set_scale_down.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_replica_set_scale_up.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_replica_set_test_mtls.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_s3_based_backup_restore.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_scale_down_cluster.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_scale_up_cluster.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_scale_up_cluster_new_cluster.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_scram.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_split_horizon.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_sts_override.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_tls_no_mesh.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_tls_with_scram.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_tls_with_x509.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_upgrade_downgrade.py create mode 100644 docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_validation.py create mode 100644 docker/mongodb-enterprise-tests/tests/olm/__init__.py create mode 100644 docker/mongodb-enterprise-tests/tests/olm/fixtures/olm_ops_manager_backup.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/olm/fixtures/olm_replica_set_for_om.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/olm/fixtures/olm_scram_sha_user_backing_db.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/olm/fixtures/olm_sharded_cluster_for_om.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/olm/olm_operator_upgrade.py create mode 100644 docker/mongodb-enterprise-tests/tests/olm/olm_operator_upgrade_with_resources.py create mode 100644 docker/mongodb-enterprise-tests/tests/olm/olm_test_commons.py create mode 100644 docker/mongodb-enterprise-tests/tests/operator/__init__.py create mode 100644 docker/mongodb-enterprise-tests/tests/operator/operator_clusterwide.py create mode 100644 docker/mongodb-enterprise-tests/tests/operator/operator_partial_crd.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/__init__.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/backup_snapshot_schedule_tests.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/conftest.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/ca-tls-full-chain.crt create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/ca-tls.crt create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/ca-tls.key create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/downloads.mongodb.com.chained+root.crt create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/mongodb-download.crt create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/mongodb_versions_claim.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_appdb_configure_all_images.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_appdb_scale_up_down.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_appdb_scram.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_appdb_upgrade.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_backup_delete_sts.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_https_enabled.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_localmode-multiple-pv.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_localmode-single-pv.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_appdb_monitoring_tls.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_appdb_upgrade_tls.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_backup.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_backup_irsa.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_backup_kmip.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_backup_light.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_backup_tls.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_backup_tls_s3.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_basic.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_full.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_jvm_params.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_pod_spec.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_scale.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_secure_config.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_upgrade.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_s3store_validation.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_validation.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/remote_fixtures/nginx-config.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/remote_fixtures/nginx-svc.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/remote_fixtures/nginx.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/remote_fixtures/om_remotemode.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/replica-set-for-om.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/replica-set-kmip.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/scram-sha-user-backing-db.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/sharded-cluster-for-om.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/upgrade_appdb.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_appdb_multi_change.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_appdb_scram.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_appdb_validation.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_external_connectivity.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_jvm_params.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_localmode_multiple_pv.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_localmode_single_pv.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_delete_sts.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_irsa.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_kmip.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_manual.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_restore.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_restore_minio.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_s3_tls.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_sharded_cluster.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_tls.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_tls_custom_ca.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_feature_controls.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_https.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_https_hybrid_mode.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_https_internet_mode.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_https_prefix.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_local_mode_enable_and_disable_manually_deleting_sts.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_prometheus.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_queryable_backup.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_scale.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_update_before_reconciliation.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_upgrade.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_weak_password.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_remotemode.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/om_validation_webhook.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/__init__.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/conftest.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_appdb_agent_flags.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_appdb_configure_all_images.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_appdb_scale_up_down.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_appdb_upgrade.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_ops_manager_appdb_monitoring_tls.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_ops_manager_backup_light.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_ops_manager_backup_liveness_probe.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_ops_manager_pod_spec.py create mode 100644 docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_ops_manager_secure_config.py create mode 100644 docker/mongodb-enterprise-tests/tests/probes/conftest.py create mode 100644 docker/mongodb-enterprise-tests/tests/probes/fixtures/deployment_tls.json create mode 100644 docker/mongodb-enterprise-tests/tests/probes/replication_state_awareness.py create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/__init__.py create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/conftest.py create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-8-members.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-custom-podspec.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-double.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-downgrade.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-ent.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-externally-exposed.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-invalid.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-liveness.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-mongod-options.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-pv-multiple.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-pv.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-single.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-upgrade.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/replica_set.py create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/replica_set_agent_flags.py create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/replica_set_config_map.py create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/replica_set_custom_podspec.py create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/replica_set_custom_sa.py create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/replica_set_exposed_externally.py create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/replica_set_groups.py create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/replica_set_liveness_probe.py create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/replica_set_member_options.py create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/replica_set_mongod_options.py create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/replica_set_process_hostnames.py create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/replica_set_pv.py create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/replica_set_pv_multiple.py create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/replica_set_readiness_probe.py create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/replica_set_recovery.py create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/replica_set_report_pending_pods.py create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/replica_set_schema_validation.py create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/replica_set_statefulset_status.py create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/replica_set_update_delete_parallel.py create mode 100644 docker/mongodb-enterprise-tests/tests/replicaset/replica_set_upgrade_downgrade.py create mode 100644 docker/mongodb-enterprise-tests/tests/shardedcluster/__init__.py create mode 100644 docker/mongodb-enterprise-tests/tests/shardedcluster/conftest.py create mode 100644 docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster-custom-podspec.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster-downgrade.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster-mongod-options.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster-pv.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster-scale-down-shards.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster-scale-shards.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster-single.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster.py create mode 100644 docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_agent_flags.py create mode 100644 docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_custom_podspec.py create mode 100644 docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_mongod_options.py create mode 100644 docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_pv.py create mode 100644 docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_recovery.py create mode 100644 docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_scale_down_shards.py create mode 100644 docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_scale_shards.py create mode 100644 docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_schema_validation.py create mode 100644 docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_secret.py create mode 100644 docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_statefulset_status.py create mode 100644 docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_upgrade_downgrade.py create mode 100644 docker/mongodb-enterprise-tests/tests/standalone/__init__.py create mode 100644 docker/mongodb-enterprise-tests/tests/standalone/conftest.py create mode 100644 docker/mongodb-enterprise-tests/tests/standalone/fixtures/standalone-custom-podspec.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/standalone/fixtures/standalone-downgrade.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/standalone/fixtures/standalone.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/standalone/fixtures/standalone_pv_invalid.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/standalone/fixtures/test_storage_class.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/standalone/standalone_config_map.py create mode 100644 docker/mongodb-enterprise-tests/tests/standalone/standalone_custom_podspec.py create mode 100644 docker/mongodb-enterprise-tests/tests/standalone/standalone_groups.py create mode 100644 docker/mongodb-enterprise-tests/tests/standalone/standalone_recovery.py create mode 100644 docker/mongodb-enterprise-tests/tests/standalone/standalone_schema_validation.py create mode 100644 docker/mongodb-enterprise-tests/tests/standalone/standalone_set_agent_flags.py create mode 100644 docker/mongodb-enterprise-tests/tests/standalone/standalone_type_change_recovery.py create mode 100644 docker/mongodb-enterprise-tests/tests/standalone/standalone_upgrade_downgrade.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/__init__.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/conftest.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/e2e_configure_tls_and_x509_simultaneously_rs.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/e2e_configure_tls_and_x509_simultaneously_sc.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/e2e_configure_tls_and_x509_simultaneously_standalone.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/e2e_tls_disable_and_scale_up.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/fixtures/node-port-service.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/tls/fixtures/server-key.pem create mode 100644 docker/mongodb-enterprise-tests/tests/tls/fixtures/test-no-tls-no-status.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-additional-domains.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs-allow-ssl.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs-external-access.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs-prefer-ssl.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs-require-ssl-custom-ca.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs-require-ssl-upgrade.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs-require-ssl.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs-x509.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-sc-require-ssl-custom-ca.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-sc-require-ssl.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-rs-external-access-multiple-horizons.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-sc-additional-domains.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/tls/fixtures/test-x509-all-options-rs.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/tls/fixtures/test-x509-all-options-sc.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/tls/fixtures/test-x509-rs.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/tls/fixtures/test-x509-sc-custom-ca.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/tls/fixtures/test-x509-sc.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/tls/fixtures/test-x509-user.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/tls/fixtures/x509-testing-user.csr create mode 100644 docker/mongodb-enterprise-tests/tests/tls/tls_allowssl.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/tls_multiple_different_ssl_configs.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/tls_no_status.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/tls_permissions_default.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/tls_permissions_override.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/tls_preferssl.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/tls_replica_set_process_hostnames.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/tls_replicaset_certsSecretPrefix.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/tls_requiressl.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/tls_requiressl_and_disable.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/tls_requiressl_to_allow.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/tls_requiressl_upgrade.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/tls_rs_additional_certs.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/tls_rs_external_access.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/tls_rs_external_access_manual_connectivity.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/tls_rs_external_access_transitions_without_approval.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/tls_rs_intermediate_ca.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/tls_sc_additional_certs.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/tls_sc_requiressl_custom_ca.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/tls_sharded_cluster_certsSecretPrefix.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/tls_sharded_cluster_certs_prefix.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/tls_x509_configure_all_options_rs.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/tls_x509_configure_all_options_sc.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/tls_x509_rs.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/tls_x509_sc.py create mode 100644 docker/mongodb-enterprise-tests/tests/tls/tls_x509_user_connectivity.py create mode 100644 docker/mongodb-enterprise-tests/tests/upgrades/__init__.py create mode 100644 docker/mongodb-enterprise-tests/tests/upgrades/operator_upgrade_appdb_tls.py create mode 100644 docker/mongodb-enterprise-tests/tests/upgrades/operator_upgrade_ops_manager.py create mode 100644 docker/mongodb-enterprise-tests/tests/upgrades/operator_upgrade_replica_set.py create mode 100644 docker/mongodb-enterprise-tests/tests/users/__init__.py create mode 100644 docker/mongodb-enterprise-tests/tests/users/conftest.py create mode 100644 docker/mongodb-enterprise-tests/tests/users/fixtures/user_with_roles.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/users/fixtures/users_multiple.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/users/users_addition_removal.py create mode 100644 docker/mongodb-enterprise-tests/tests/users/users_schema_validation.py create mode 100644 docker/mongodb-enterprise-tests/tests/vaultintegration/__init__.py create mode 100644 docker/mongodb-enterprise-tests/tests/vaultintegration/conftest.py create mode 100644 docker/mongodb-enterprise-tests/tests/vaultintegration/mongodb_deployment_vault.py create mode 100644 docker/mongodb-enterprise-tests/tests/vaultintegration/om_backup_vault.py create mode 100644 docker/mongodb-enterprise-tests/tests/vaultintegration/om_deployment_vault.py create mode 100644 docker/mongodb-enterprise-tests/tests/vaultintegration/vault_tls.py create mode 100644 docker/mongodb-enterprise-tests/tests/webhooks/__init__.py create mode 100644 docker/mongodb-enterprise-tests/tests/webhooks/conftest.py create mode 100644 docker/mongodb-enterprise-tests/tests/webhooks/e2e_mongodb_roles_validation_webhook.py create mode 100644 docker/mongodb-enterprise-tests/tests/webhooks/e2e_mongodb_validation_webhook.py create mode 100644 docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_appdb_shard_count.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_mdb_member_count.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_agent_auth_not_in_modes.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_auth_no_modes.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_horizons_members.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_horizons_tls.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_ldap_community.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_ldapauthz_no_ldapgroupdn.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_no_agent_mode.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_x509_no_tls.yaml create mode 100644 docker/mongodb-enterprise-tests/tests/webhooks/fixtures/role-validation-base.yaml create mode 100644 docker/mongodb-enterprise-tests/vaultconfig/override.yaml create mode 100644 docker/mongodb-enterprise-tests/vaultpolicies/appdb-policy.hcl create mode 100644 docker/mongodb-enterprise-tests/vaultpolicies/database-policy.hcl create mode 100644 docker/mongodb-enterprise-tests/vaultpolicies/operator-policy.hcl create mode 100644 docker/mongodb-enterprise-tests/vaultpolicies/opsmanager-policy.hcl create mode 100644 docker/mongodb-enterprise-tests/vendor/README.md create mode 100644 docker/mongodb-enterprise-tests/vendor/openldap/.helmignore create mode 100644 docker/mongodb-enterprise-tests/vendor/openldap/Chart.yaml create mode 100644 docker/mongodb-enterprise-tests/vendor/openldap/README.md create mode 100644 docker/mongodb-enterprise-tests/vendor/openldap/templates/NOTES.txt create mode 100644 docker/mongodb-enterprise-tests/vendor/openldap/templates/_helpers.tpl create mode 100644 docker/mongodb-enterprise-tests/vendor/openldap/templates/configmap-customldif.yaml create mode 100644 docker/mongodb-enterprise-tests/vendor/openldap/templates/configmap-env.yaml create mode 100644 docker/mongodb-enterprise-tests/vendor/openldap/templates/deployment.yaml create mode 100644 docker/mongodb-enterprise-tests/vendor/openldap/templates/pvc.yaml create mode 100644 docker/mongodb-enterprise-tests/vendor/openldap/templates/secret.yaml create mode 100644 docker/mongodb-enterprise-tests/vendor/openldap/templates/service.yaml create mode 100644 docker/mongodb-enterprise-tests/vendor/openldap/templates/tests/openldap-test-runner.yaml create mode 100644 docker/mongodb-enterprise-tests/vendor/openldap/templates/tests/openldap-tests.yaml create mode 100644 docker/mongodb-enterprise-tests/vendor/openldap/values.yaml create mode 100644 docs/investigation/pod-is-killed-while-agent-restores.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 hack/boilerplate.go.txt create mode 100644 helm_chart/Chart.yaml create mode 100644 helm_chart/crds/mongodb.com_mongodb.yaml create mode 100644 helm_chart/crds/mongodb.com_mongodbmulticluster.yaml create mode 100644 helm_chart/crds/mongodb.com_mongodbusers.yaml create mode 100644 helm_chart/crds/mongodb.com_opsmanagers.yaml create mode 100644 helm_chart/templates/_helpers.tpl create mode 100644 helm_chart/templates/database-roles.yaml create mode 100644 helm_chart/templates/operator-roles.yaml create mode 100644 helm_chart/templates/operator.yaml create mode 100644 helm_chart/templates/secret-config.yaml create mode 100644 helm_chart/values-openshift.yaml create mode 100644 helm_chart/values.yaml create mode 100644 inventories/daily.yaml create mode 100644 inventories/database.yaml create mode 100644 inventories/init_appdb.yaml create mode 100644 inventories/init_database.yaml create mode 100644 inventories/init_om.yaml create mode 100644 inventories/om.yaml create mode 100644 inventories/test.yaml create mode 100644 inventory.yaml create mode 100644 licenses.csv create mode 100644 main.go create mode 100644 multi_cluster/cluster.yaml create mode 100755 multi_cluster/create_security_groups.py create mode 100755 multi_cluster/setup_multi_cluster_environment.sh create mode 100644 multi_cluster/tools/README.md create mode 100755 multi_cluster/tools/download_istio.sh create mode 100755 multi_cluster/tools/install_istio.sh create mode 100755 multi_cluster/tools/install_istio_central.sh create mode 100755 pipeline.py create mode 100644 pipeline_test.py create mode 100644 pkg/dns/dns.go create mode 100644 pkg/handler/enqueue_owner_multi.go create mode 100644 pkg/kube/kube.go create mode 100644 pkg/multicluster/failedcluster/failedcluster.go create mode 100644 pkg/multicluster/memberwatch/clusterhealth.go create mode 100644 pkg/multicluster/memberwatch/clusterhealth_test.go create mode 100644 pkg/multicluster/memberwatch/memberwatch.go create mode 100644 pkg/multicluster/memberwatch/memberwatch_test.go create mode 100644 pkg/multicluster/mockedcluster.go create mode 100644 pkg/multicluster/multicluster.go create mode 100644 pkg/multicluster/multicluster_test.go create mode 100644 pkg/passwordhash/passwordhash.go create mode 100644 pkg/statefulset/statefulset_test.go create mode 100644 pkg/statefulset/statefulset_util.go create mode 100644 pkg/tls/tls.go create mode 100644 pkg/util/constants.go create mode 100644 pkg/util/env/env.go create mode 100644 pkg/util/generate/generate.go create mode 100644 pkg/util/identifiable/identifiable.go create mode 100644 pkg/util/int/int_util.go create mode 100644 pkg/util/int/int_util_test.go create mode 100644 pkg/util/manifest/version.go create mode 100644 pkg/util/maputil/mapmerge.go create mode 100644 pkg/util/maputil/mapmerge_test.go create mode 100644 pkg/util/maputil/maputil.go create mode 100644 pkg/util/maputil/maputil_test.go create mode 100644 pkg/util/mergo_utils.go create mode 100644 pkg/util/stringutil/stringutil.go create mode 100644 pkg/util/stringutil/stringutil_test.go create mode 100644 pkg/util/timeutil/timeutil.go create mode 100644 pkg/util/util.go create mode 100644 pkg/util/util_test.go create mode 100644 pkg/util/versionutil/versionutil.go create mode 100644 pkg/util/versionutil/versionutil_test.go create mode 100644 pkg/vault/vault.go create mode 100644 pkg/vault/vaultwatcher/vaultsecretwatch.go create mode 100644 pkg/webhook/certificates.go create mode 100644 pkg/webhook/setup.go create mode 100644 production_notes/README.md create mode 100644 production_notes/cmd/runtest/pvc.yaml create mode 100644 production_notes/cmd/runtest/runtest.go create mode 100644 production_notes/cmd/setup/setup.go create mode 100644 production_notes/deploy/basic.yaml create mode 100644 production_notes/deploy/big.yaml create mode 100644 production_notes/deploy/medium.yaml create mode 100644 production_notes/deploy/small.yaml create mode 100644 production_notes/deploy/storageClass.yaml create mode 100644 production_notes/docker/Dockerfile create mode 100644 production_notes/docker/run.sh create mode 100644 production_notes/helm_charts/mongodb/certs/.helmignore create mode 100644 production_notes/helm_charts/mongodb/certs/Chart.yaml create mode 100644 production_notes/helm_charts/mongodb/certs/ca_tls.crt create mode 100644 production_notes/helm_charts/mongodb/certs/ca_tls.key create mode 100644 production_notes/helm_charts/mongodb/certs/templates/ca-issuer.yaml create mode 100644 production_notes/helm_charts/mongodb/certs/templates/ca-key-pair.yaml create mode 100644 production_notes/helm_charts/mongodb/certs/templates/issuer-ca.yaml create mode 100644 production_notes/helm_charts/mongodb/certs/templates/pod-certs.yaml create mode 100644 production_notes/helm_charts/mongodb/replicaset/.helmignore create mode 100644 production_notes/helm_charts/mongodb/replicaset/Chart.yaml create mode 120000 production_notes/helm_charts/mongodb/replicaset/crds create mode 100644 production_notes/helm_charts/mongodb/replicaset/templates/binding.yaml create mode 100644 production_notes/helm_charts/mongodb/replicaset/templates/database-cm.yaml create mode 100644 production_notes/helm_charts/mongodb/replicaset/templates/database-secret.yaml create mode 100644 production_notes/helm_charts/mongodb/replicaset/templates/database.yaml create mode 100644 production_notes/helm_charts/mongodb/replicaset/templates/mongodb-user-password.yaml create mode 100644 production_notes/helm_charts/mongodb/replicaset/templates/mongodb-user.yaml create mode 100644 production_notes/helm_charts/mongodb/values.yaml create mode 100644 production_notes/helm_charts/operator/.helmignore create mode 100644 production_notes/helm_charts/operator/Chart.yaml create mode 120000 production_notes/helm_charts/operator/crds create mode 100644 production_notes/helm_charts/operator/templates/operator-roles.yaml create mode 100644 production_notes/helm_charts/operator/templates/operator.yaml create mode 100644 production_notes/helm_charts/operator/values.yaml create mode 100644 production_notes/helm_charts/opsmanager/.helmignore create mode 100644 production_notes/helm_charts/opsmanager/Chart.lock create mode 100644 production_notes/helm_charts/opsmanager/Chart.yaml create mode 120000 production_notes/helm_charts/opsmanager/crds create mode 100644 production_notes/helm_charts/opsmanager/templates/ops-manager-global-admin.yaml create mode 100644 production_notes/helm_charts/opsmanager/templates/ops-manager.yaml create mode 100644 production_notes/helm_charts/opsmanager/templates/opsmanager-roles.yaml create mode 100644 production_notes/helm_charts/opsmanager/values.yaml create mode 100644 production_notes/helm_charts/ycsb/.helmignore create mode 100644 production_notes/helm_charts/ycsb/Chart.yaml create mode 120000 production_notes/helm_charts/ycsb/ca_tls.crt create mode 100644 production_notes/helm_charts/ycsb/templates/job.yaml create mode 100644 production_notes/helm_charts/ycsb/templates/workload.configmap.yaml create mode 100644 production_notes/helm_charts/ycsb/templates/ycsb_secret.yaml create mode 100644 production_notes/helm_charts/ycsb/values.yaml create mode 100644 production_notes/helm_charts/ycsb/workload-a.yaml create mode 100644 production_notes/monitoring/00-ns.yaml create mode 100644 production_notes/monitoring/02-role.yaml create mode 100644 production_notes/monitoring/03-pvc.yaml create mode 100644 production_notes/monitoring/04-cm.yaml create mode 100644 production_notes/monitoring/05-deployment.yaml create mode 100644 production_notes/monitoring/06-svc-int.yaml create mode 100644 production_notes/monitoring/07-grafana-pvc.yaml create mode 100644 production_notes/monitoring/08-grafana-dep.yaml create mode 100644 production_notes/monitoring/09-grafana-svc.yaml create mode 100644 production_notes/monitoring/10-prom-ext.yaml create mode 100644 production_notes/pkg/monitor/monitor.go create mode 100644 production_notes/pkg/provisioner/provisioner.go create mode 100644 production_notes/pkg/s3/s3.go create mode 100644 production_notes/pkg/ycsb/ycsb.go create mode 100644 public/.github/ISSUE_TEMPLATE/bug_report.md create mode 100644 public/.github/ISSUE_TEMPLATE/config.yml create mode 100644 public/.github/PULL_REQUEST_TEMPLATE.md create mode 100644 public/.github/workflows/release-multicluster-cli.yaml create mode 100644 public/LICENSE create mode 100644 public/README.md create mode 100644 public/crds.yaml create mode 100644 public/dockerfiles/mongodb-agent/10.29.0.6830-1/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/10.29.0.6830-1/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/11.0.1.6929-1/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/11.0.1.6929-1/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/11.0.11.7036-1/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/11.0.11.7036-1/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/11.0.12.7051-1/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/11.0.12.7051-1/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/11.0.13.7055-1/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/11.0.13.7055-1/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/11.0.14.7064-1/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/11.0.14.7064-1/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/11.0.15.7073-1/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/11.0.15.7073-1/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/11.0.16.7080-1/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/11.0.16.7080-1/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/11.0.17.7084-1/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/11.0.17.7084-1/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/11.0.19.7094-1/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/11.0.19.7094-1/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/11.0.5.6963-1/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/11.0.5.6963-1/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/11.12.0.7388-1/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/11.12.0.7388-1/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/12.0.10.7591-1/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/12.0.11.7606-1/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/12.0.15.7646-1/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/12.0.4.7554-1/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/12.0.4.7554-1/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/12.0.8.7575-1/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-agent/12.0.8.7575-1/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-appdb/10.2.15.5958-1_4.2.11-ent/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-appdb/10.2.15.5958-1_4.2.11-ent/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-database/2.0.0/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-database/2.0.0/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-database/2.0.1/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-database/2.0.1/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-database/2.0.2/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-database/2.0.2/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-appdb/1.0.10/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-appdb/1.0.10/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-appdb/1.0.11/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-appdb/1.0.11/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-appdb/1.0.12/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-appdb/1.0.12/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-appdb/1.0.13/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-appdb/1.0.13/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-appdb/1.0.14/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-appdb/1.0.14/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-appdb/1.0.6/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-appdb/1.0.6/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-appdb/1.0.7/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-appdb/1.0.7/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-appdb/1.0.8/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-appdb/1.0.8/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-appdb/1.0.9/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-appdb/1.0.9/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-database/1.0.10/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-database/1.0.10/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-database/1.0.11/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-database/1.0.11/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-database/1.0.12/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-database/1.0.12/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-database/1.0.13/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-database/1.0.13/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-database/1.0.14/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-database/1.0.14/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-database/1.0.2/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-database/1.0.2/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-database/1.0.3/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-database/1.0.3/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-database/1.0.4/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-database/1.0.4/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-database/1.0.5/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-database/1.0.5/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-database/1.0.6/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-database/1.0.6/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-database/1.0.7/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-database/1.0.7/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-database/1.0.8/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-database/1.0.8/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-database/1.0.9/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-database/1.0.9/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.10/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.10/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.3/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.3/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.4/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.4/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.5/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.5/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.6/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.6/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.7/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.7/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.8/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.8/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.9/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.9/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.10.0/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.10.0/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.11.0/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.11.0/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.12.0/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.12.0/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.13.0/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.13.0/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.14.0/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.14.0/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.15.0/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.15.0/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.15.1/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.15.1/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.15.2/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.15.2/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.16.0/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.16.0/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.16.1/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.16.1/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.16.2/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.16.2/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.16.3/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.16.3/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.16.4/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.16.4/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.17.0/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.17.0/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.17.1/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.17.1/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.17.2/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.17.2/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.18.0/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.18.0/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.9.0/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.9.0/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.9.1/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.9.1/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.9.2/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-operator/1.9.2/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.2.26/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.2.26/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.10/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.10/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.11/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.11/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.12/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.12/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.13/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.13/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.14/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.14/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.15/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.15/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.16/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.16/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.17/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.17/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.18/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.18/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.19/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.19/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.20/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.20/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.21/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.21/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.22/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.22/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.23/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.23/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.24/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.24/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.7/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.7/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.9/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/4.4.9/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.0/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.0/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.1/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.1/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.10/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.10/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.11/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.11/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.12/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.12/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.13/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.13/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.14/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.14/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.15/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.15/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.16/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.16/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.17/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.17/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.2/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.2/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.3/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.3/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.4/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.4/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.5/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.5/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.6/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.6/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.7/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.7/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.8/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.8/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.9/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/5.0.9/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/6.0.0/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/6.0.0/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/6.0.1/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/6.0.1/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/6.0.2/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/6.0.2/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/6.0.3/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/6.0.3/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/6.0.4/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/6.0.4/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/6.0.5/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/6.0.5/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/6.0.6/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/6.0.6/ubuntu/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/6.0.7/ubi/Dockerfile create mode 100644 public/dockerfiles/mongodb-enterprise-ops-manager/6.0.7/ubuntu/Dockerfile create mode 100644 public/docs/assets/image--000.png create mode 100644 public/docs/assets/image--002.png create mode 100644 public/docs/assets/image--004.png create mode 100644 public/docs/assets/image--008.png create mode 100644 public/docs/assets/image--014.png create mode 100644 public/docs/assets/image--030.png create mode 100644 public/docs/assets/image--032.png create mode 100644 public/docs/assets/image--034.png create mode 100644 public/docs/openshift-marketplace.md create mode 100644 public/docs/upgrading-to-ops-manager-5.md create mode 100644 public/grafana/sample_dashboard.json create mode 100644 public/mongodb-enterprise-multi-cluster.yaml create mode 100644 public/mongodb-enterprise-openshift.yaml create mode 100644 public/mongodb-enterprise.yaml create mode 100644 public/multi_cluster_verify/sample-service.yaml create mode 100644 public/opa_examples/README.md create mode 100644 public/opa_examples/debugging/constraint_template.yaml create mode 100644 public/opa_examples/debugging/constraints.yaml create mode 100644 public/opa_examples/mongodb_allow_replicaset/constraints.yaml create mode 100644 public/opa_examples/mongodb_allow_replicaset/mongodb_allow_replicaset.yaml create mode 100644 public/opa_examples/mongodb_allowed_versions/constraints.yaml create mode 100644 public/opa_examples/mongodb_allowed_versions/mongodb_allowed_versions.yaml create mode 100644 public/opa_examples/mongodb_strict_tls/constraints.yaml create mode 100644 public/opa_examples/mongodb_strict_tls/mongodb_strict_tls.yaml create mode 100644 public/opa_examples/ops_manager_allowed_versions/constraints.yaml create mode 100644 public/opa_examples/ops_manager_allowed_versions/ops_manager_allowed_versions.yaml create mode 100644 public/opa_examples/ops_manager_replica_members/constraints.yaml create mode 100644 public/opa_examples/ops_manager_replica_members/ops_manager_replica_members.yaml create mode 100644 public/opa_examples/ops_manager_wizardless/constraints.yaml create mode 100644 public/opa_examples/ops_manager_wizardless/ops_manager_wizardless_template.yaml create mode 100644 public/samples/mongodb/affinity/replica-set-affinity.yaml create mode 100644 public/samples/mongodb/affinity/sharded-cluster-affinity.yaml create mode 100644 public/samples/mongodb/affinity/standalone-affinity.yaml create mode 100644 public/samples/mongodb/agent-startup-options/replica-set-agent-startup-options.yaml create mode 100644 public/samples/mongodb/agent-startup-options/sharded-cluster-agent-startup-options.yaml create mode 100644 public/samples/mongodb/agent-startup-options/standalone-agent-startup-options.yaml create mode 100644 public/samples/mongodb/authentication/ldap/replica-set/replica-set-ldap-user.yaml create mode 100644 public/samples/mongodb/authentication/ldap/replica-set/replica-set-ldap.yaml create mode 100644 public/samples/mongodb/authentication/ldap/sharded-cluster/sharded-cluster-ldap-user.yaml create mode 100644 public/samples/mongodb/authentication/ldap/sharded-cluster/sharded-cluster-ldap.yaml create mode 100644 public/samples/mongodb/authentication/scram/replica-set/replica-set-scram-password.yaml create mode 100644 public/samples/mongodb/authentication/scram/replica-set/replica-set-scram-sha.yaml create mode 100644 public/samples/mongodb/authentication/scram/replica-set/replica-set-scram-user.yaml create mode 100644 public/samples/mongodb/authentication/scram/sharded-cluster/sharded-cluster-scram-password.yaml create mode 100644 public/samples/mongodb/authentication/scram/sharded-cluster/sharded-cluster-scram-sha.yaml create mode 100644 public/samples/mongodb/authentication/scram/sharded-cluster/sharded-cluster-scram-user.yaml create mode 100644 public/samples/mongodb/authentication/scram/standalone/standalone-scram-password.yaml create mode 100644 public/samples/mongodb/authentication/scram/standalone/standalone-scram-sha.yaml create mode 100644 public/samples/mongodb/authentication/scram/standalone/standalone-scram-user.yaml create mode 100644 public/samples/mongodb/authentication/x509/replica-set/replica-set-x509.yaml create mode 100644 public/samples/mongodb/authentication/x509/replica-set/user.yaml create mode 100644 public/samples/mongodb/authentication/x509/sharded-cluster/sharded-cluster-x509.yaml create mode 100644 public/samples/mongodb/authentication/x509/sharded-cluster/user.yaml create mode 100644 public/samples/mongodb/backup/replica-set-backup-disabled.yaml create mode 100644 public/samples/mongodb/backup/replica-set-backup.yaml create mode 100644 public/samples/mongodb/external-connectivity/replica-set-external.yaml create mode 100644 public/samples/mongodb/minimal/replica-set.yaml create mode 100644 public/samples/mongodb/minimal/sharded-cluster.yaml create mode 100644 public/samples/mongodb/minimal/standalone.yaml create mode 100644 public/samples/mongodb/mongodb-options/replica-set-mongod-options.yaml create mode 100644 public/samples/mongodb/mongodb-options/sharded-cluster-mongod-options.yaml create mode 100644 public/samples/mongodb/persistent-volumes/replica-set-persistent-volumes.yaml create mode 100644 public/samples/mongodb/persistent-volumes/sharded-cluster-persistent-volumes.yaml create mode 100644 public/samples/mongodb/persistent-volumes/standalone-persistent-volumes.yaml create mode 100644 public/samples/mongodb/pod-template/initcontainer-sysctl_config.yaml create mode 100644 public/samples/mongodb/pod-template/replica-set-pod-template.yaml create mode 100644 public/samples/mongodb/pod-template/sharded-cluster-pod-template.yaml create mode 100644 public/samples/mongodb/pod-template/standalone-pod-template.yaml create mode 100644 public/samples/mongodb/podspec/replica-set-podspec.yaml create mode 100644 public/samples/mongodb/podspec/sharded-cluster-podspec.yaml create mode 100644 public/samples/mongodb/podspec/standalone-podspec.yaml create mode 100644 public/samples/mongodb/project.yaml create mode 100644 public/samples/mongodb/prometheus/replica-set.yaml create mode 100644 public/samples/mongodb/prometheus/sharded-cluster.yaml create mode 100644 public/samples/mongodb/tls/replica-set/replica-set-tls.yaml create mode 100644 public/samples/mongodb/tls/sharded-cluster/sharded-cluster-tls.yaml create mode 100644 public/samples/mongodb/tls/standalone/standalone-tls.yaml create mode 100644 public/samples/mongodb_multicluster/replica-set-configure-storage.yaml create mode 100644 public/samples/mongodb_multicluster/replica-set-sts-override.yaml create mode 100644 public/samples/mongodb_multicluster/replica-set.yaml create mode 100644 public/samples/multi-cluster-cli-gitops/README.md create mode 100644 public/samples/multi-cluster-cli-gitops/argocd/application.yaml create mode 100644 public/samples/multi-cluster-cli-gitops/argocd/project.yaml create mode 100644 public/samples/multi-cluster-cli-gitops/resources/job.yaml create mode 100644 public/samples/multi-cluster-cli-gitops/resources/rbac/cluster_scoped_central_cluster.yaml create mode 100644 public/samples/multi-cluster-cli-gitops/resources/rbac/cluster_scoped_member_cluster.yaml create mode 100644 public/samples/multi-cluster-cli-gitops/resources/rbac/namespace_scoped_central_cluster.yaml create mode 100644 public/samples/multi-cluster-cli-gitops/resources/rbac/namespace_scoped_member_cluster.yaml create mode 100644 public/samples/multi-cluster-cli-gitops/resources/replica-set.yaml create mode 100644 public/samples/ops-manager/ops-manager-appdb-agent-startup-parameters.yaml create mode 100644 public/samples/ops-manager/ops-manager-appdb-custom-images.yaml create mode 100644 public/samples/ops-manager/ops-manager-backup.yaml create mode 100644 public/samples/ops-manager/ops-manager-disable-appdb-process.yaml create mode 100644 public/samples/ops-manager/ops-manager-external.yaml create mode 100644 public/samples/ops-manager/ops-manager-ignore-ui-setup.yaml create mode 100644 public/samples/ops-manager/ops-manager-local-mode.yaml create mode 100644 public/samples/ops-manager/ops-manager-non-root.yaml create mode 100644 public/samples/ops-manager/ops-manager-pod-spec.yaml create mode 100644 public/samples/ops-manager/ops-manager-remote-mode.yaml create mode 100644 public/samples/ops-manager/ops-manager-scram.yaml create mode 100644 public/samples/ops-manager/ops-manager-tls.yaml create mode 100644 public/samples/ops-manager/ops-manager.yaml create mode 100755 public/support/certificate_rotation.sh create mode 100755 public/support/mdb_operator_diagnostic_data.sh create mode 100644 public/tools/multicluster/.gitignore create mode 100644 public/tools/multicluster/.goreleaser.yaml create mode 100644 public/tools/multicluster/Dockerfile create mode 100644 public/tools/multicluster/LICENSE create mode 100644 public/tools/multicluster/cmd/debug.go create mode 100644 public/tools/multicluster/cmd/multicluster.go create mode 100644 public/tools/multicluster/cmd/recover.go create mode 100644 public/tools/multicluster/cmd/root.go create mode 100644 public/tools/multicluster/cmd/setup.go create mode 100644 public/tools/multicluster/go.mod create mode 100644 public/tools/multicluster/go.sum create mode 100755 public/tools/multicluster/install_istio_separate_network.sh create mode 100644 public/tools/multicluster/licenses.csv create mode 100644 public/tools/multicluster/main.go create mode 100644 public/tools/multicluster/pkg/common/common.go create mode 100644 public/tools/multicluster/pkg/common/common_test.go create mode 100644 public/tools/multicluster/pkg/common/kubeclientcontainer.go create mode 100644 public/tools/multicluster/pkg/common/kubeconfig.go create mode 100644 public/tools/multicluster/pkg/common/utils.go create mode 100644 public/tools/multicluster/pkg/debug/anonymize.go create mode 100644 public/tools/multicluster/pkg/debug/anonymize_test.go create mode 100644 public/tools/multicluster/pkg/debug/collectors.go create mode 100644 public/tools/multicluster/pkg/debug/collectors_test.go create mode 100644 public/tools/multicluster/pkg/debug/writer.go create mode 100644 public/tools/multicluster/pkg/debug/writer_test.go create mode 100644 public/vault_policies/appdb-policy.hcl create mode 100644 public/vault_policies/database-policy.hcl create mode 100644 public/vault_policies/operator-policy.hcl create mode 100644 public/vault_policies/opsmanager-policy.hcl create mode 100644 release.json create mode 100644 requirements.txt create mode 100755 scripts/dev/apply_resources create mode 100755 scripts/dev/configure_docker_auth.sh create mode 100755 scripts/dev/configure_operator.sh create mode 100755 scripts/dev/delete_om_projects.sh create mode 100755 scripts/dev/deploy_operator.sh create mode 100755 scripts/dev/ensure_k8s.sh create mode 100755 scripts/dev/evg_host.sh create mode 100755 scripts/dev/install.sh create mode 100755 scripts/dev/interconnect_kind_clusters.sh create mode 100755 scripts/dev/kind_clusters_check_interconnect.sh create mode 100755 scripts/dev/launch_e2e.sh create mode 100755 scripts/dev/prepare_local_e2e_olm_run.sh create mode 100755 scripts/dev/prepare_local_e2e_run.sh create mode 100755 scripts/dev/print_automation_config create mode 100755 scripts/dev/print_contexts create mode 100755 scripts/dev/print_operator_env.sh create mode 100755 scripts/dev/read_context.sh create mode 100755 scripts/dev/recreate_e2e_kops.sh create mode 100755 scripts/dev/recreate_kind_cluster.sh create mode 100755 scripts/dev/recreate_kind_clusters.sh create mode 100755 scripts/dev/reset.sh create mode 100644 scripts/dev/samples/README.md create mode 100644 scripts/dev/samples/dev create mode 100644 scripts/dev/samples/kind create mode 100644 scripts/dev/samples/multi create mode 100644 scripts/dev/samples/multi-kind create mode 100644 scripts/dev/samples/openshift create mode 100644 scripts/dev/samples/quay create mode 100755 scripts/dev/set_env_context.sh create mode 100755 scripts/dev/setup_kind_cluster.sh create mode 100755 scripts/dev/status create mode 100755 scripts/dev/switch_context.sh create mode 100755 scripts/evergreen/add_evergreen_task.sh create mode 100755 scripts/evergreen/build_multi_cluster_kubeconfig_creator.sh create mode 100755 scripts/evergreen/check_precommit.sh create mode 100755 scripts/evergreen/configure-docker-datadir.sh create mode 100644 scripts/evergreen/deployments/multi-cluster-roles/Chart.yaml create mode 100644 scripts/evergreen/deployments/multi-cluster-roles/templates/mongodb-enterprise-tests.yaml create mode 100644 scripts/evergreen/deployments/multi-cluster-roles/values.yaml create mode 100644 scripts/evergreen/deployments/ops-manager-vanilla.yaml create mode 100644 scripts/evergreen/deployments/test-app/Chart.yaml create mode 100644 scripts/evergreen/deployments/test-app/templates/mongodb-enterprise-tests.yaml create mode 100644 scripts/evergreen/deployments/test-app/values.yaml create mode 100644 scripts/evergreen/deployments/values-ops-manager.yaml create mode 100755 scripts/evergreen/e2e/configure_operator.sh create mode 100755 scripts/evergreen/e2e/dump_diagnostic_information create mode 100755 scripts/evergreen/e2e/e2e.sh create mode 100755 scripts/evergreen/e2e/fetch_om_information create mode 100755 scripts/evergreen/e2e/lib create mode 100755 scripts/evergreen/e2e/setup_cloud_qa.py create mode 100755 scripts/evergreen/e2e/single_e2e.sh create mode 100755 scripts/evergreen/e2e/teardown.sh create mode 100644 scripts/evergreen/flakiness-report.py create mode 100755 scripts/evergreen/generate_evergreen_expansions.sh create mode 100755 scripts/evergreen/lint_code.sh create mode 100755 scripts/evergreen/operator-sdk/install-olm.sh create mode 100755 scripts/evergreen/operator-sdk/prepare-openshift-bundles-for-e2e.sh create mode 100755 scripts/evergreen/operator-sdk/prepare-openshift-bundles.sh create mode 100755 scripts/evergreen/prepare_aws.sh create mode 100755 scripts/evergreen/prepare_test_env.sh create mode 100644 scripts/evergreen/release/helm_files_handler.py create mode 100755 scripts/evergreen/release/update_helm_values_files.py create mode 100755 scripts/evergreen/release/update_release.py create mode 100755 scripts/evergreen/release_blocker create mode 100644 scripts/evergreen/requirements.txt create mode 100755 scripts/evergreen/retry-evergreen.sh create mode 100755 scripts/evergreen/setup_aws.sh create mode 100755 scripts/evergreen/setup_jq.sh create mode 100755 scripts/evergreen/setup_kind.sh create mode 100755 scripts/evergreen/setup_kubectl.sh create mode 100755 scripts/evergreen/setup_kubernetes_environment.sh create mode 100755 scripts/evergreen/setup_minikube.sh create mode 100755 scripts/evergreen/setup_preflight.sh create mode 100755 scripts/evergreen/setup_prepare_openshift_bundles.sh create mode 100755 scripts/evergreen/setup_shellcheck.sh create mode 100755 scripts/evergreen/setup_yq.sh create mode 100755 scripts/evergreen/should_prepare_openshift_bundles.sh create mode 100755 scripts/evergreen/tag_push_docker_image.sh create mode 100755 scripts/evergreen/teardown_kubernetes_environment.sh create mode 100755 scripts/evergreen/unit-tests.sh create mode 100755 scripts/evergreen/update_licenses.sh create mode 100644 scripts/evergreen/update_licenses.tpl create mode 100644 scripts/funcs/checks create mode 100644 scripts/funcs/errors create mode 100644 scripts/funcs/install create mode 100644 scripts/funcs/kubernetes create mode 100644 scripts/funcs/multicluster create mode 100644 scripts/funcs/operator_deployment create mode 100644 scripts/funcs/printing create mode 100755 scripts/preflight_images.py create mode 100644 scripts/update_dockerfiles_in_s3.py create mode 100755 scripts/update_supported_dockerfiles.py create mode 100644 tools.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..c50bd93dc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +vendor/** +pkg/client/** +samples/* +public/* +.git/** +bin/** diff --git a/.evergreen-periodic-builds.yaml b/.evergreen-periodic-builds.yaml new file mode 100644 index 000000000..9cffa2c3d --- /dev/null +++ b/.evergreen-periodic-builds.yaml @@ -0,0 +1,503 @@ +parameters: +- key: registry + value: 268558157000.dkr.ecr.us-east-1.amazonaws.com + description: Development ECR registry + +- key: pin_tag_at + value: 00:00 + description: Pin tags at this time of the day. Midnight by default. + +variables: + - &go_bin + "/opt/golang/go1.20/bin" + - &go_options + GOROOT: "/opt/golang/go1.20" + +### All the functions in this file are copies of what is in +### .evergreen.yml. If there's any modification on these, it should be also +### modified in there. +functions: + clone: + - command: subprocess.exec + type: setup + params: + command: "mkdir -p src/github.com/10gen" + - command: git.get_project + type: setup + params: + directory: src/github.com/10gen/ops-manager-kubernetes + + setup_preflight: + - command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + add_to_path: + - ${workdir}/bin + include_expansions_in_env: + - workdir + binary: scripts/evergreen/setup_preflight.sh + - command: subprocess.exec + type: setup + params: + command: python3 -m venv --upgrade ./venv + - command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + command: ${workdir}/venv/bin/python -m pip install -r requirements.txt + + setup_prepare_openshift_bundles: + - command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + add_to_path: + - ${workdir}/bin + include_expansions_in_env: + - workdir + command: scripts/evergreen/setup_yq.sh + - command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + add_to_path: + - ${workdir}/bin + include_expansions_in_env: + - workdir + command: scripts/evergreen/setup_prepare_openshift_bundles.sh + + preflight_image: + - command: subprocess.exec + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + add_to_path: + - ${workdir}/bin + include_expansions_in_env: + - image_version + - project_id + - rh_pyxis + command: "${workdir}/venv/bin/python scripts/preflight_images.py --image ${image_name} --submit ${preflight_submit}" + + configure_docker_auth: + - command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + add_to_path: + - ${workdir}/bin + include_expansions_in_env: + - workdir + binary: scripts/evergreen/setup_aws.sh + - command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + add_to_path: + - ${workdir}/bin + include_expansions_in_env: + - workdir + env: + AWS_ACCESS_KEY_ID: ${mms_eng_test_aws_access_key} + AWS_SECRET_ACCESS_KEY: ${mms_eng_test_aws_secret} + AWS_DEFAULT_REGION: ${mms_eng_test_aws_region} + binary: scripts/dev/configure_docker_auth.sh + - command: subprocess.exec + type: setup + params: + command: "docker login quay.io -u ${quay_prod_username} -p ${quay_prod_robot_token}" + + build_and_push_appdb_database: + - command: subprocess.exec + params: + include_expansions_in_env: + - workdir + working_dir: src/github.com/10gen/ops-manager-kubernetes/docker/mongodb-enterprise-appdb-database + binary: ./build_and_push_appdb_database_images.sh + add_to_path: + - ${workdir}/bin + - ${workdir} + env: + AWS_ACCESS_KEY_ID: ${mms_eng_test_aws_access_key} + AWS_SECRET_ACCESS_KEY: ${mms_eng_test_aws_secret} + AWS_DEFAULT_REGION: ${mms_eng_test_aws_region} + + + pipeline: + - command: subprocess.exec + type: setup + params: + command: /opt/python/3.9/bin/python3 -m venv --upgrade-deps ./venv + - command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + command: ${workdir}/venv/bin/python -m pip install -r requirements.txt --quiet --no-warn-script-location + - command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + env: + AWS_ACCESS_KEY_ID: ${mms_eng_test_aws_access_key} + AWS_SECRET_ACCESS_KEY: ${mms_eng_test_aws_secret} + AWS_DEFAULT_REGION: ${mms_eng_test_aws_region} + include_expansions_in_env: + - version_id + - registry + - distro + - include_tags + - skip_tags + - test_suffix + - om_version + - created_at + - pin_tag_at + add_to_path: + - ${workdir}/bin + command: "${workdir}/venv/bin/python pipeline.py --include ${image_name}" + +# Updates current expansions with variables from release.json file. +# Use e.g. ${mongoDbOperator} afterwards. + update_evergreen_expansions: + - command: subprocess.exec + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + add_to_path: + - ${workdir}/bin + command: "scripts/evergreen/generate_evergreen_expansions.sh" + - command: expansions.update + params: + file: "src/github.com/10gen/ops-manager-kubernetes/evergreen_expansions.yaml" + +# Uploads openshift bundle specified by bundle_file_name argument. + upload_openshift_bundle: + - command: s3.put + params: + aws_key: ${enterprise_aws_access_key_id} + aws_secret: ${enterprise_aws_secret_access_key} + local_file: src/github.com/10gen/ops-manager-kubernetes/bundle/${bundle_file_name} + remote_file: bundles/${bundle_file_name} + bucket: operator-e2e-bundles + permissions: public-read + content_type: application/x-binary + + prepare_openshift_bundles: + - command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + add_to_path: + - ${workdir}/bin + - *go_bin # make bundle uses go install to install controller-gen and kustomize + include_expansions_in_env: + - workdir + env: + <<: [ *go_options ] + command: scripts/evergreen/operator-sdk/prepare-openshift-bundles.sh + + # This is a generic function for conditionally running given task. + # It works by appending to if returns no error. + # + # It has 3 input parameters: + # - condition_script: path to the script that will be executed. + # Error code == 0 resulting from the scripts indicates that should be added dynamically to + # Error code != 0 means that the task will not be executed + # - variant: variant to which task will be appended + # - task: task name to be executed + # + # Example usage: + # - func: run_task_conditionally + # vars: + # condition_script: scripts/evergreen/should_prepare_openshift_bundles.sh + # variant: prepare_openshift_bundles + # task: prepare_and_upload_openshift_bundles + run_task_conditionally: + - command: shell.exec + params: + shell: bash + working_dir: src/github.com/10gen/ops-manager-kubernetes + script: | + if ${condition_script}; then + echo "Adding ${task} task to ${variant} variant" + scripts/evergreen/add_evergreen_task.sh ${variant} ${task} + else + echo "skipping task ${task} due to ${condition_script} result: $?" + fi + - command: generate.tasks + params: + files: + - evergreen_tasks.json + optional: true + +tasks: +- name: preflight_images + commands: + - func: clone + - func: setup_preflight + - func: preflight_image + vars: + image_name: operator + project_id: ${rhc_operator_pid} + - func: preflight_image + vars: + image_name: ops-manager + project_id: ${rhc_om_pid} + - func: preflight_image + vars: + image_name: init-appdb + project_id: ${rhc_init_appdb_pid} + - func: preflight_image + vars: + image_name: init-database + project_id: ${rhc_init_database_pid} + - func: preflight_image + vars: + image_name: init-ops-manager + project_id: ${rhc_init_om_pid} + - func: preflight_image + vars: + image_name: database + project_id: ${rhc_database_pid} + - func: preflight_image + vars: + image_name: mongodb-enterprise-server # official server images + project_id: ${rhc_official_database_pid} + - func: preflight_image + vars: + image_name: mongodb-agent + project_id: ${rhc_agent_pid} + +- name: periodic_build_operator + commands: + - func: clone + - func: configure_docker_auth + vars: + project_id: ${rhc_operator_pid} + - func: pipeline + vars: + image_name: operator-daily + +- name: periodic_build_init_appdb + commands: + - func: clone + - func: configure_docker_auth + vars: + project_id: ${rhc_init_appdb_pid} + - func: pipeline + vars: + image_name: init-appdb-daily + +- name: periodic_build_init_database + commands: + - func: clone + - func: configure_docker_auth + vars: + project_id: ${rhc_init_database_pid} + - func: pipeline + vars: + image_name: init-database-daily + +- name: periodic_build_init_opsmanager + commands: + - func: clone + - func: configure_docker_auth + vars: + project_id: ${rhc_init_om_pid} + - func: pipeline + vars: + image_name: init-ops-manager-daily + +- name: periodic_build_database + commands: + - func: clone + - func: configure_docker_auth + vars: + project_id: ${rhc_database_pid} + - func: pipeline + vars: + image_name: database-daily + +- name: periodic_build_ops_manager_5 + commands: + - func: clone + - func: configure_docker_auth + vars: + project_id: ${rhc_om_pid} + - func: pipeline + vars: + image_name: ops-manager-5-daily + +- name: periodic_build_ops_manager_6 + commands: + - func: clone + - func: configure_docker_auth + vars: + project_id: ${rhc_om_pid} + - func: pipeline + vars: + image_name: ops-manager-6-daily + +- name: periodic_build_agent + commands: + - func: clone + - func: configure_docker_auth + vars: + project_id: ${rhc_agent_pid} + - func: pipeline + vars: + image_name: mongodb-agent-daily + +- name: periodic_build_community_operator + commands: + - func: clone + - func: configure_docker_auth + vars: + project_id: ${rhc_mongodb_community_operator_pid} + - func: pipeline + vars: + image_name: mongodb-kubernetes-operator-daily + +- name: periodic_build_readiness_probe + commands: + - func: clone + - func: configure_docker_auth + vars: + project_id: ${rhc_mongodb_readiness_probe_pid} + - func: pipeline + vars: + image_name: mongodb-kubernetes-readinessprobe-daily + +- name: periodic_build_version_upgrade_post_start_hook + commands: + - func: clone + - func: configure_docker_auth + vars: + project_id: ${rhc_mongodb_build_version_upgrade_post_start_hook_pid} + - func: pipeline + vars: + image_name: mongodb-kubernetes-operator-version-upgrade-post-start-hook-daily + +- name: periodic_build_appdb_database + commands: + - func: clone + - func: configure_docker_auth + vars: + project_id: ${rhc_appdb_database_pid} + - func: build_and_push_appdb_database + +- name: prepare_and_upload_openshift_bundles + commands: + - func: clone + - func: configure_docker_auth + - func: setup_prepare_openshift_bundles + - func: prepare_openshift_bundles + - func: update_evergreen_expansions + - func: upload_openshift_bundle + vars: + # mongoDbOperator expansion is added in update_evergreen_expansions func from release.json + bundle_file_name: "operator-community-${mongodbOperator}.tgz" + - func: upload_openshift_bundle + vars: + bundle_file_name: "operator-certified-${mongodbOperator}.tgz" + +- name: run_conditionally_prepare_and_upload_openshift_bundles + commands: + - func: clone + - func: run_task_conditionally + vars: + condition_script: scripts/evergreen/should_prepare_openshift_bundles.sh + variant: prepare_openshift_bundles + task: prepare_and_upload_openshift_bundles + +task_groups: + - name: periodic_build_task_group + max_hosts: 5 + tasks: + - periodic_build_operator + - periodic_build_readiness_probe + - periodic_build_version_upgrade_post_start_hook + - periodic_build_init_appdb + - periodic_build_init_database + - periodic_build_init_opsmanager + - periodic_build_ops_manager_5 + - periodic_build_ops_manager_6 + - periodic_build_database + - periodic_build_agent + - periodic_build_community_operator + - periodic_build_appdb_database + + - name: preflight_images_task_group + tasks: + - preflight_images + +buildvariants: +- name: periodic_build + display_name: periodic_build + run_on: + - ubuntu1804-small + tasks: + - name: periodic_build_task_group + +- name: preflight_images + display_name: preflight_images + depends_on: + # We have to add every task in the periodic build variant here + # because otherwise evergreen moves on to the preflight task after + # a single task in the referenced task group succeeds. + - name: periodic_build_operator + variant: periodic_build + - name: periodic_build_init_appdb + variant: periodic_build + - name: periodic_build_init_database + variant: periodic_build + - name: periodic_build_init_opsmanager + variant: periodic_build + - name: periodic_build_ops_manager_5 + variant: periodic_build + - name: periodic_build_ops_manager_6 + variant: periodic_build + - name: periodic_build_database + variant: periodic_build + - name: periodic_build_agent + variant: periodic_build + - name: periodic_build_appdb_database + variant: periodic_build + run_on: + - rhel90-small + expansions: + preflight_submit: true + tasks: + - name: preflight_images_task_group + +- name: prepare_openshift_bundles + display_name: prepare_openshift_bundles + depends_on: + # We have to add every task in the periodic build variant here + # because otherwise evergreen moves on to the preflight task after + # a single task in the referenced task group succeeds. + - name: periodic_build_operator + variant: periodic_build + - name: periodic_build_readiness_probe + variant: periodic_build + - name: periodic_build_version_upgrade_post_start_hook + variant: periodic_build + - name: periodic_build_init_appdb + variant: periodic_build + - name: periodic_build_init_database + variant: periodic_build + - name: periodic_build_init_opsmanager + variant: periodic_build + - name: periodic_build_ops_manager_5 + variant: periodic_build + - name: periodic_build_ops_manager_6 + variant: periodic_build + - name: periodic_build_database + variant: periodic_build + - name: periodic_build_agent + variant: periodic_build + - name: periodic_build_appdb_database + variant: periodic_build + run_on: + - ubuntu2204-small + tasks: + - name: run_conditionally_prepare_and_upload_openshift_bundles + diff --git a/.evergreen.yml b/.evergreen.yml new file mode 100644 index 000000000..c013f6193 --- /dev/null +++ b/.evergreen.yml @@ -0,0 +1,2762 @@ +ignore: + - "public/support/*" + - "public/samples/*" + +stepback: true + +# 2h timeout for all the tasks +exec_timeout_secs: 7200 + +variables: + # These are OM/MDB versions used in OM e2e tests (build variants for 4.4/5.0 OM) - change them occasionally + - &ops_manager_50_prev 5.0.1 + + - &ops_manager_50_latest 5.0.20 # The order/index is important, since these are anchors. Please do not change + - &ops_manager_50_appdb_version 4.4.20-ent + + - &ops_manager_60_latest 6.0.13 # The order/index is important, since these are anchors. Please do not change + - &ops_manager_60_appdb_version 6.0.5-ent + + - &ops_manager_50_current + ops_manager_version: *ops_manager_50_latest + ops_manager_namespace: "operator-testing-50-current" + node_port: 30044 + - &cloud_manager_qa + ops_manager_version: "cloud_qa" + + # Latest MDB release as of today. Update this to latest `rc`s or GA version when + # it is released. + # https://opsmanager.mongodb.com/static/version_manifest/5.0.json + - &mdb_50_latest 5.0.5 + - &mdb_50_prev 5.0.1 + + # Latest MDB release as of today. Update this to latest `rc`s or GA version when + # it is released. + # https://opsmanager.mongodb.com/static/version_manifest/6.0.json + - &mdb_60_latest 6.0.5 + + - &mdb_44_latest 4.4.20 + + # Latest available version of Kubernetes + # https://kubernetes.io/releases/ + - &kubernetes_kind_version 1.22.0 + + # This is the automation agent version used for the AppDB + # Agent version used in OM50 + - &agent_version50 11.0.11.7036-1 + # Agent version used in OM60 + - &agent_version60 12.0.4.7554-1 + # Fallback option as some targets rely on expending this + - &agent_version 11.0.11.7036-1 + + # Openshift v4 Testing Environment + - &kubernetes_environment_openshift_4 + kube_environment_name: openshift_4 + ecr_registry_needs_auth: ecr-registry + managed_security_context: "true" + always_remove_testing_namespace: "true" + + # Kops Vanilla Kubernetes + - &kubernetes_environment_vanilla + kube_environment_name: vanilla + + - &kubernetes_environment_multi_cluster_kind + kube_environment_name: multi + CLUSTER_TYPE: kind + member_clusters: "kind-e2e-cluster-1 kind-e2e-cluster-2 kind-e2e-cluster-3" + central_cluster: kind-e2e-operator + test_pod_cluster: kind-e2e-cluster-1 + + # Multi Cluster Environment with 2 clusters + - &kubernetes_environment_multi_cluster_2_clusters + kube_environment_name: multi + CLUSTER_TYPE: kind + member_clusters: "kind-e2e-cluster-1 kind-e2e-cluster-2" + central_cluster: kind-e2e-cluster-1 + test_pod_cluster: kind-e2e-cluster-1 + + - &kubernetes_environment_kind + kube_environment_name: kind + # we can use kind to test ubi images, however we must disable managed security context + managed_security_context: "false" + + - &go_bin + "/opt/golang/go1.20/bin" + + - &go_options + GOROOT: "/opt/golang/go1.20" + + - &e2e_cloud_qa_ubi_cloudqa + e2e_cloud_qa_orgid_owner: ${e2e_cloud_qa_orgid_owner_ubi_cloudqa} + e2e_cloud_qa_apikey_owner: ${e2e_cloud_qa_apikey_owner_ubi_cloudqa} + e2e_cloud_qa_user_owner: ${e2e_cloud_qa_user_owner_ubi_cloudqa} + + - &e2e_include_expansions_in_env + include_expansions_in_env: + - always_remove_testing_namespace + - custom_appdb_version + - custom_om_version + - custom_om_prev_version + - custom_mdb_version + - custom_mdb_prev_version + - ecr_registry + - ecr_registry_needs_auth + - kube_environment_name + - ops_manager_version + - version_id + - install_operator_in_python + - task_id + - GITHUB_TOKEN_READ + - usaf_operator_version + - usaf_database_version + - agent_version + - central_cluster + - member_clusters + - test_pod_cluster + - CLUSTER_TYPE + + - &e2e_environment_variables + REGISTRY: ${ecr_registry}/dev/${image_type} + OPS_MANAGER_REGISTRY: ${ops_manager_registry|quay.io/mongodb} + APPDB_REGISTRY: ${appdb_registry|quay.io/mongodb} + DATABASE_REGISTRY: ${ecr_registry}/dev/${image_type} + INIT_OPS_MANAGER_REGISTRY: ${ecr_registry}/dev/${image_type} + INIT_APPDB_REGISTRY: ${ecr_registry}/dev/${image_type} + INIT_DATABASE_REGISTRY: ${ecr_registry}/dev/${image_type} + MANAGED_SECURITY_CONTEXT: ${managed_security_context} + TASK_NAME: ${task_name} + OPS_MANAGER_NAMESPACE: ${ops_manager_namespace} + OPS_MANAGER_ENV: ${workdir}/.ops-manager-env + NAMESPACE_FILE: ${workdir}/.namespace + NODE_PORT: ${node_port} + AWS_ACCESS_KEY_ID: ${mms_eng_test_aws_access_key} + AWS_SECRET_ACCESS_KEY: ${mms_eng_test_aws_secret} + MMS_VERSION: ${ops_manager_version} + WATCH_NAMESPACE: ${watch_namespace} + STATIC_NAMESPACE: ${static_namespace} + TEST_MODE: ${test_mode} + KUBECONFIG: ${workdir}/${kube_environment_name}_config + IMAGE_TYPE: ${image_type} + KUBE_ENVIRONMENT_NAME: ${kube_environment_name} + CLUSTER_TYPE: ${CLUSTER_TYPE} + VERSION_ID: ${version_id} + + +parameters: +- key: registry + value: 268558157000.dkr.ecr.us-east-1.amazonaws.com + description: Development ECR registry + +- key: appdb_registry + value: quay.io/mongodb + description: registry where appdb images are stored for this run + +- key: database_registry + value: quay.io/mongodb + description: registry where database images are stored for this run + +- key: readiness_probe + value: "" + description: set this to the repository:tag of the readinessprobe image + +- key: evergreen_retry + value: "true" + description: set this to false to suppress retries on failure + +functions: + + "clone": + - command: subprocess.exec + type: setup + params: + command: "mkdir -p src/github.com/10gen" + - command: git.get_project + type: setup + params: + directory: src/github.com/10gen/ops-manager-kubernetes + + build_multi_cluster_binary: + - command: subprocess.exec + type: setup + params: + add_to_path: + - *go_bin + working_dir: src/github.com/10gen/ops-manager-kubernetes + include_expansions_in_env: + - workdir + env: + <<: [ *go_options ] + binary: scripts/evergreen/build_multi_cluster_kubeconfig_creator.sh + + test_operator: + - command: subprocess.exec + type: test + params: + add_to_path: + - *go_bin + - ${workdir}/bin + working_dir: src/github.com/10gen/ops-manager-kubernetes + include_expansions_in_env: + - workdir + env: + <<: [ *go_options ] + command: make test + + python_venv: &python_venv + command: subprocess.exec + type: setup + params: + command: /opt/python/3.9/bin/python3 -m venv --upgrade-deps ./venv + + python_requirements: &python_requirements + command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + command: ${workdir}/venv/bin/python -m pip install -r requirements.txt --quiet --no-warn-script-location + + python_dev_requirements: &python_dev_requirements + command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + command: ${workdir}/venv/bin/python -m pip install -r docker/mongodb-enterprise-tests/requirements-dev.txt --quiet --no-warn-script-location + + setup_preflight: + - command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + add_to_path: + - ${workdir}/bin + include_expansions_in_env: + - workdir + binary: scripts/evergreen/setup_preflight.sh + - command: subprocess.exec + type: setup + params: + command: python3 -m venv --upgrade ./venv + - command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + command: ${workdir}/venv/bin/python -m pip install -r requirements.txt + + preflight_image: + - command: subprocess.exec + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + add_to_path: + - ${workdir}/bin + include_expansions_in_env: + - image_version + - rh_pyxis + command: "${workdir}/venv/bin/python scripts/preflight_images.py --image ${image_name} --submit ${preflight_submit}" + + pipeline: + - *python_venv + - *python_requirements + - command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + env: + AWS_ACCESS_KEY_ID: ${mms_eng_test_aws_access_key} + AWS_SECRET_ACCESS_KEY: ${mms_eng_test_aws_secret} + AWS_DEFAULT_REGION: ${mms_eng_test_aws_region} + include_expansions_in_env: + - version_id + - registry + - distro + - include_tags + - skip_tags + - test_suffix + - om_version + - om_download_url + - triggered_by_git_tag + - readiness_probe + add_to_path: + - ${workdir}/bin + command: "${workdir}/venv/bin/python pipeline.py --include ${image_name}" + + upload_dockerfiles: + - command: s3.put + params: + aws_key: ${enterprise_aws_access_key_id} + aws_secret: ${enterprise_aws_secret_access_key} + local_file: src/github.com/10gen/ops-manager-kubernetes/public/dockerfiles-${triggered_by_git_tag}.tgz + remote_file: bundles/dockerfiles-${triggered_by_git_tag}.tgz + bucket: operator-e2e-bundles + permissions: public-read + content_type: application/x-binary + + # upload_e2e_logs has the responsibility of dumping as much information as + # possible into the S3 bucket that corresponds to this ${version}. The + # Kubernetes cluster where the test finished running, should still be + # reachable. Note that after a timeout, Evergreen kills the running process + # and any running container in the host (which kills Kind). + upload_e2e_logs: + - command: s3.put + params: + aws_key: ${enterprise_aws_access_key_id} + aws_secret: ${enterprise_aws_secret_access_key} + local_files_include_filter: + - src/github.com/10gen/ops-manager-kubernetes/logs/* + remote_file: logs/${task_id}/${execution}/ + bucket: operator-e2e-artifacts + permissions: public-read + content_type: text/plain + - command: attach.xunit_results + params: + file: "src/github.com/10gen/ops-manager-kubernetes/logs/myreport.xml" + + + # cleanup_exec_environment is a very generic name when the only thing this function + # does is to clean the logs directory. In the future, more "commands" can be + # added to it with more clearing features, when needed. + cleanup_exec_environment: + - command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + command: "rm -rf logs/" + + quay_login: + - command: subprocess.exec + type: setup + params: + command: "docker login quay.io -u ${quay_prod_username} -p ${quay_prod_robot_token}" + + # Logs into all used registries + configure_docker_auth: &configure_docker_auth + command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + add_to_path: + - ${workdir}/bin + include_expansions_in_env: + - workdir + env: + AWS_ACCESS_KEY_ID: ${mms_eng_test_aws_access_key} + AWS_SECRET_ACCESS_KEY: ${mms_eng_test_aws_secret} + AWS_DEFAULT_REGION: ${mms_eng_test_aws_region} + binary: scripts/dev/configure_docker_auth.sh + + lint_repo: + - *python_venv + - *python_dev_requirements + - *python_requirements + - command: subprocess.exec + type: test + params: + env: + <<: [ *go_options ] + add_to_path: + - *go_bin + - ${workdir}/bin + - ${workdir}/venv/bin + working_dir: src/github.com/10gen/ops-manager-kubernetes + include_expansions_in_env: + - workdir + - GITHUB_TOKEN_READ + binary: scripts/evergreen/check_precommit.sh + + + setup_jq: &setup_jq + command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + include_expansions_in_env: + - workdir + binary: scripts/evergreen/setup_jq.sh + + setup_shellcheck: + command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + add_to_path: + - ${workdir}/bin + include_expansions_in_env: + - workdir + binary: scripts/evergreen/setup_shellcheck.sh + + setup_aws: &setup_aws + command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + add_to_path: + - ${workdir}/bin + include_expansions_in_env: + - workdir + binary: scripts/evergreen/setup_aws.sh + + # configures Docker size, installs the Kind binary (if necessary) + setup_kind: &setup_kind + command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + add_to_path: + - ${workdir}/bin + include_expansions_in_env: + - workdir + - kube_environment_name + - CLUSTER_TYPE + binary: scripts/evergreen/setup_kind.sh + + setup_docker_datadir: &setup_docker_datadir + command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + binary: scripts/evergreen/configure-docker-datadir.sh + + # Configures docker authentication to ECR and RH registries. + setup_building_host: + - *setup_aws + - *configure_docker_auth + - *setup_docker_datadir + + prune_docker_resources: + - command: subprocess.exec + type: setup + params: + command: "docker system prune -a -f" + + # the task configures the set of tools necessary for any task working with K8 cluster: + # installs kubectl, jq, kind (if necessary), configures docker authentication + download_kube_tools: + - command: subprocess.exec + type: setup + params: + include_expansions_in_env: + - workdir + working_dir: src/github.com/10gen/ops-manager-kubernetes + binary: scripts/evergreen/setup_kubectl.sh + + - *setup_jq + # we need aws to configure docker authentication + - *setup_aws + + - *configure_docker_auth + + - *setup_kind + + teardown_kubernetes_environment: + - command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + add_to_path: + - ${workdir}/bin + include_expansions_in_env: + - kube_environment_name + - workdir + binary: scripts/evergreen/teardown_kubernetes_environment.sh + + # Makes sure a kubectl context is defined. + setup_kubernetes_environment_p: &setup_kubernetes_environment_p + command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + include_expansions_in_env: + - cluster_name + - CLUSTER_TYPE + - central_cluster + - member_clusters + - test_pod_cluster + - kube_environment_name + - mms_eng_test_aws_access_key + - mms_eng_test_aws_secret + - mms_eng_test_aws_region + - openshift_url + - openshift_token + - workdir + env: + kubernetes_kind_version: *kubernetes_kind_version + add_to_path: + - ${workdir}/bin + binary: scripts/evergreen/setup_kubernetes_environment.sh + + setup_kubernetes_environment: + - *setup_kubernetes_environment_p + + setup_cloud_qa_ubi_cloudqa: + - command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + env: + NAMESPACE_FILE: ${workdir}/.namespace + ENV_FILE: ${workdir}/.ops-manager-env + <<: [ *e2e_cloud_qa_ubi_cloudqa ] + include_expansions_in_env: + - e2e_cloud_qa_baseurl + - ops_manager_version + - task_name + + command: "scripts/evergreen/e2e/setup_cloud_qa.py create" + + setup_cloud_qa: + - command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + env: + NAMESPACE_FILE: ${workdir}/.namespace + ENV_FILE: ${workdir}/.ops-manager-env + include_expansions_in_env: + - e2e_cloud_qa_baseurl + - e2e_cloud_qa_orgid_owner + - e2e_cloud_qa_apikey_owner + - e2e_cloud_qa_user_owner + - ops_manager_version + - task_name + + command: "scripts/evergreen/e2e/setup_cloud_qa.py create" + + teardown_cloud_qa: + - command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + add_to_path: + - ${workdir}/bin + include_expansions_in_env: + - e2e_cloud_qa_baseurl + - e2e_cloud_qa_orgid_owner + - e2e_cloud_qa_apikey_owner + - e2e_cloud_qa_user_owner + - ops_manager_version + env: + NAMESPACE_FILE: ${workdir}/.namespace + ENV_FILE: ${workdir}/.ops-manager-env + + command: "scripts/evergreen/e2e/setup_cloud_qa.py delete" + + teardown_cloud_qa_ubi_cloudqa: + - command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + add_to_path: + - ${workdir}/bin + include_expansions_in_env: + - e2e_cloud_qa_baseurl + - ops_manager_version + env: + NAMESPACE_FILE: ${workdir}/.namespace + ENV_FILE: ${workdir}/.ops-manager-env + <<: [ *e2e_cloud_qa_ubi_cloudqa ] + + command: "scripts/evergreen/e2e/setup_cloud_qa.py delete" + + # This is a blocker for the release process. It will *always* fail and needs to be overriden + # if the release needs to proceed. + release_blocker: + - command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + binary: scripts/evergreen/release_blocker + + # Tags and pushes an image into an external Docker registry. The source image + # needs to exist before it can be pushed to a remote registry. + # It is expected that IMAGE_SOURCE is accessible with no authentication (like a + # local image), and the IMAGE_TARGET will be authenticated with DOCKER_* series of + # environment variables. + release_docker_image_to_registry: + - command: subprocess.exec + type: system + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + add_to_path: + - ${workdir}/bin + include_expansions_in_env: + - tag_source + - tag_dest + - image_source + - image_target + - docker_username + - docker_password + env: + AWS_ACCESS_KEY_ID: ${mms_eng_test_aws_access_key} + AWS_SECRET_ACCESS_KEY: ${mms_eng_test_aws_secret} + + binary: scripts/evergreen/tag_push_docker_image.sh + + # + # e2e_test is the main function used to run the e2e tests. It expects Ops + # Manager to be running (local to the Kubernetes cluster or Cloud Manager) and + # its configuration to exist in a ${workdir}/.ops-manager-env file. + # + # The e2e script will run all the tasks that are needed by the e2e tests like + # fetching the OM API credentials to use and create the Secret and ConfigMap + # objects that are required. + # + # At this point, the Kubernetes environment should be configured already + # (kubectl configuration points to the Kubernetes cluster where we run the tests). + # + # Please note: There are many ENV variables passed to the `e2e` script, so try + # to not add more. If this is required, discuss your use case with the team first. + # + e2e_test: + - command: subprocess.exec + type: test + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + add_to_path: + - ${workdir}/bin + <<: *e2e_include_expansions_in_env + env: + <<: *e2e_environment_variables + binary: scripts/evergreen/e2e/e2e.sh + + run_retry_script: + - command: shell.exec + type: test + params: + shell: bash + working_dir: src/github.com/10gen/ops-manager-kubernetes + include_expansions_in_env: + - EVERGREEN_API_KEY + - EVERGREEN_USER + - evergreen_retry + env: + EVERGREEN_RETRY: ${evergreen_retry} + script: | + scripts/evergreen/retry-evergreen.sh ${version_id} + + # + # Performs some K8s cluster fixing things and also deploys a cluster cleaner. + # Optionally (if $ops_manager_namespace is specified) deploys the test OM + # + prepare_test_env: + - command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + add_to_path: + - ${workdir}/bin + env: + AWS_ACCESS_KEY_ID: ${mms_eng_test_aws_access_key} + AWS_SECRET_ACCESS_KEY: ${mms_eng_test_aws_secret} + AWS_DEFAULT_REGION: ${mms_eng_test_aws_region} + KUBECONFIG: ${workdir}/${kube_environment_name}_config + include_expansions_in_env: + - ecr_registry + - version_id + - kube_environment_name + - member_clusters + - central_cluster + - test_pod_cluster + command: "scripts/evergreen/prepare_test_env.sh ${ops_manager_namespace} ${ops_manager_version} ${node_port}" + # + # Performs some AWS cleanup + # + prepare_aws: + - command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + add_to_path: + - ${workdir}/bin + env: + AWS_ACCESS_KEY_ID: ${mms_eng_test_aws_access_key} + AWS_SECRET_ACCESS_KEY: ${mms_eng_test_aws_secret} + AWS_DEFAULT_REGION: ${mms_eng_test_aws_region} + + command: scripts/evergreen/prepare_aws.sh + + build-dockerfiles: + - *python_venv + - *python_requirements + - command: subprocess.exec + type: setup + params: + add_to_path: + - *go_bin + - ${workdir}/bin + working_dir: src/github.com/10gen/ops-manager-kubernetes + include_expansions_in_env: + - workdir + env: + <<: [ *go_options ] + command: "${workdir}/venv/bin/python scripts/update_supported_dockerfiles.py" + - command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + include_expansions_in_env: + - triggered_by_git_tag + - workdir + command: "tar -czvf ./public/dockerfiles-${triggered_by_git_tag}.tgz ./public/dockerfiles" + + setup_prepare_openshift_bundles: + - command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + add_to_path: + - ${workdir}/bin + include_expansions_in_env: + - workdir + command: scripts/evergreen/setup_yq.sh + - command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + add_to_path: + - ${workdir}/bin + include_expansions_in_env: + - workdir + command: scripts/evergreen/setup_prepare_openshift_bundles.sh + + install_olm: + - command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + add_to_path: + - ${workdir}/bin + include_expansions_in_env: + - workdir + env: + KUBECONFIG: ${workdir}/${kube_environment_name}_config + command: scripts/evergreen/operator-sdk/install-olm.sh + + prepare_openshift_bundles_for_e2e: + - command: subprocess.exec + type: setup + params: + working_dir: src/github.com/10gen/ops-manager-kubernetes + add_to_path: + - *go_bin + - ${workdir}/bin + <<: *e2e_include_expansions_in_env + env: + <<: [ *e2e_environment_variables, *go_options ] + command: scripts/evergreen/operator-sdk/prepare-openshift-bundles-for-e2e.sh + +tasks: +- name: preflight_release_images + commands: + - func: clone + - func: setup_preflight + - func: preflight_image + vars: + image_name: operator + - func: preflight_image + vars: + image_name: init-appdb + - func: preflight_image + vars: + image_name: init-database + - func: preflight_image + vars: + image_name: init-ops-manager + - func: preflight_image + vars: + image_name: database + - func: preflight_image + vars: + image_name: mongodb-agent + +- name: preflight_om_image + commands: + - func: clone + - func: setup_preflight + - func: preflight_image + vars: + image_name: ops-manager + +- name: unit_tests + tags: ["unit_tests"] + commands: + - func: "test_operator" + +- name: lint_repo + tags: ["unit_tests"] + commands: + - func: lint_repo + +- name: release_blocker + git_tag_only: true + commands: + - func: clone + - func: release_blocker + +- name: release_operator + git_tag_only: true + commands: + - func: clone + - func: setup_building_host + - func: quay_login + - func: pipeline + vars: + image_name: operator + +- name: upload_dockerfiles + git_tag_only: true + commands: + - func: clone + - func: setup_building_host + - func: build-dockerfiles + - func: upload_dockerfiles + +# Releases init images to Quay +- name: release_init_appdb + git_tag_only: true + commands: + - func: clone + - func: setup_building_host + - func: quay_login + - func: pipeline + vars: + image_name: init-appdb + +- name: release_init_database + git_tag_only: true + commands: + - func: clone + - func: setup_building_host + - func: quay_login + - func: pipeline + vars: + image_name: init-database + +- name: release_init_ops_manager + git_tag_only: true + commands: + - func: clone + - func: setup_building_host + - func: quay_login + - func: pipeline + vars: + image_name: init-ops-manager + include_tags: release + +- name: build_test_image + priority: 60 + commands: + - func: clone + - func: setup_building_host + - func: build_multi_cluster_binary + - func: pipeline + vars: + image_name: test + +- name: build_operator_ubi + priority: 60 + commands: + - func: clone + - func: setup_building_host + - func: pipeline + vars: + skip_tags: ubuntu,release + distro: ubi + image_name: operator + +- name: build_init_om_images_ubi + priority: 60 + commands: + - func: clone + - func: setup_aws + - func: configure_docker_auth + - func: pipeline + vars: + image_name: init-ops-manager + skip_tags: ubuntu,release + +- name: build_init_appdb_images_ubi + commands: + - func: clone + - func: setup_building_host + - func: pipeline + vars: + image_name: init-appdb + skip_tags: ubuntu,release + +- name: build_init_database_image_ubi + commands: + - func: clone + - func: setup_building_host + - func: pipeline + vars: + image_name: init-database + skip_tags: ubuntu,release + +- name: build_database_image_ubi + commands: + - func: clone + - func: setup_building_host + - func: pipeline + vars: + image_name: database + skip_tags: ubuntu,release + +- name: prepare_cluster_vanilla + priority: 59 + depends_on: + - name: build_operator_ubi + - name: build_init_appdb_images_ubi + - name: build_init_om_images_ubi + commands: + - func: clone + - func: download_kube_tools + - func: setup_kubernetes_environment + vars: + <<: [ *kubernetes_environment_vanilla ] + - func: prepare_test_env + vars: + <<: *ops_manager_50_current + +- name: prepare_aws + priority: 59 + commands: + - func: clone + - func: setup_jq + - func: setup_aws + - func: prepare_aws + +- name: run_retry_script + tags: ["patch-run"] + commands: + - func: run_retry_script + +- name: e2e_multiple_cluster_failures + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_standalone_custom_podspec + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_standalone_schema_validation + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_schema_validation + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_sharded_cluster_schema_validation + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_users_schema_validation + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_crd_validation + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_standalone_config_map + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_standalone_groups + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_standalone_recovery + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_operator_partial_crd + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_operator_clusterwide + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_operator_multi_namespaces + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_operator_upgrade_replica_set + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_operator_upgrade_ops_manager + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_operator_upgrade_appdb_tls + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_olm_operator_upgrade + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_olm_operator_upgrade_with_resources + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_om_ops_manager_backup_delete_sts + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_om_ops_manager_backup_kmip + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_liveness_probe + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_mongodb_validation_webhook + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_mongodb_roles_validation_webhook + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_recovery + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_config_map + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_groups + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_pv + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_pv_multiple + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_exposed_externally + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_readiness_probe + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replication_state_awareness + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_statefulset_status + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_update_delete_parallel + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_tls_sc_additional_certs + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_tls_sharded_cluster_certs_prefix + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_tls_rs_additional_certs + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_tls_rs_intermediate_ca + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_tls_rs_external_access + tags: ["patch-run"] + commands: + - func: "e2e_test" + + +- name: e2e_sharded_cluster + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_sharded_cluster_pv + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_sharded_cluster_recovery + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_sharded_cluster_secret + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_sharded_cluster_scale_shards + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_sharded_cluster_statefulset_status + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_sharded_cluster_agent_flags + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_standalone_agent_flags + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_agent_flags + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_standalone_type_change_recovery + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_all_mongodb_resources_parallel + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_standalone_upgrade_downgrade + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_upgrade_downgrade + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_custom_podspec + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_custom_sa + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_report_pending_pods + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_mongod_options + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_sharded_cluster_mongod_options + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_sharded_cluster_custom_podspec + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_sharded_cluster_upgrade_downgrade + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_standalone_no_tls_no_status_is_set + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_tls_default + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_tls_override + tags: ["patch-run"] + commands: + - func: "e2e_test" + + +- name: e2e_replica_set_ignore_unknown_users + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_tls_process_hostnames + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_process_hostnames + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_member_options + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_tls_allow + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_tls_prefer + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_tls_require + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_tls_certs_secret_prefix + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_sharded_cluster_tls_certs_top_level_prefix + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_disable_tls_scale_up + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_tls_require_to_allow + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_sharded_cluster_tls_require_custom_ca + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_tls_require_and_disable + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_tls_multiple_different_ssl_configs + commands: + - func: "e2e_test" + +- name: e2e_replica_set_tls_require_upgrade + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_tls_x509_rs + tags: ["patch-run"] + # longer timeout than usual as this test tests recovery from bad states which can take some time + commands: + - func: "e2e_test" + +- name: e2e_tls_x509_sc + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_tls_x509_users_addition_removal + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_tls_x509_user_connectivity + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_tls_x509_configure_all_options_rs + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_tls_x509_configure_all_options_sc + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_scram_sha_256_user_connectivity + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_scram_sha_256_user_first + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_scram_sha_1_user_connectivity + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_sharded_cluster_scram_sha_256_user_connectivity + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_sharded_cluster_scram_sha_1_user_connectivity + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_scram_sha_1_upgrade + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_sharded_cluster_scram_sha_1_upgrade + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_x509_to_scram_transition + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_sharded_cluster_x509_to_scram_transition + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_sharded_cluster_internal_cluster_transition + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_ldap + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_sharded_cluster_ldap + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_ldap_tls + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_ldap_user_to_dn_mapping + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_ldap_agent_auth + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_ldap_agent_client_certs + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_custom_roles + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_update_roles_no_privileges + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_ldap_group_dn + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_ldap_group_dn_with_x509_agent + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_feature_controls_authentication + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_scram_sha_and_x509 + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_sharded_cluster_scram_sha_and_x509 + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_scram_x509_internal_cluster + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_replica_set_scram_x509_ic_manual_certs + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_sharded_cluster_scram_x509_ic_manual_certs + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_sharded_cluster_scram_x509_internal_cluster + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_configure_tls_and_x509_simultaneously_rs + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_configure_tls_and_x509_simultaneously_sc + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_configure_tls_and_x509_simultaneously_st + tags: ["patch-run"] + commands: + - func: "e2e_test" + +# E2E tests for Ops Manager (sorted alphabetically): +- name: e2e_om_appdb_agent_flags + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_om_appdb_monitoring_tls + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_om_appdb_multi_change + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_om_appdb_scale_up_down + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_om_appdb_scram + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_om_appdb_upgrade + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_om_appdb_validation + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_om_external_connectivity + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_om_weak_password + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_om_multiple + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_om_appdb_configure_all_images + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_om_ops_manager_backup + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_om_ops_manager_backup_sharded_cluster + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_om_ops_manager_backup_liveness_probe + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_om_ops_manager_backup_light + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_om_ops_manager_backup_tls + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_om_ops_manager_backup_s3_tls + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_om_ops_manager_backup_tls_custom_ca + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_om_ops_manager_backup_restore + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_om_ops_manager_queryable_backup + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_om_feature_controls + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_om_ops_manager_scale + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_om_ops_manager_upgrade + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_om_ops_manager_prometheus + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_om_ops_manager_pod_spec + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_om_validation_webhook + tags: ["patch-run"] + commands: + - func: "e2e_test" + +- name: e2e_om_ops_manager_https_enabled + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_om_ops_manager_https_enabled_hybrid + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_om_ops_manager_https_enabled_prefix + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_om_ops_manager_https_enabled_internet_mode + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_om_jvm_params + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_om_localmode + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_om_ops_manager_enable_local_mode_running_om + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_om_remotemode + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_om_localmode_multiple_pv + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_om_ops_manager_secure_config + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_replica_set + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_replica_set_member_options + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_replica_set_scale_up + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_scale_up_cluster + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_scale_up_cluster_new_cluster + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_scale_down_cluster + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_replica_set_scale_down + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_replica_set_deletion + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_mtls_test + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_scram + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_sts_override + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_tls_with_scram + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_enable_tls + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_upgrade_downgrade + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_tls_cert_rotation + tags: ["patch-run"] + commands: + - func: e2e_test + + +- name: e2e_multi_cluster_tls_no_mesh + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_backup_restore + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_s3_based_backup_restore + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_backup_restore_no_mesh + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_tls_with_x509 + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_with_ldap + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_with_ldap_custom_roles + tags: ["patch-run"] + commands: + - func: e2e_test + + +- name: e2e_multi_cluster_specific_namespaces + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_clusterwide + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_disaster_recovery + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_multi_disaster_recovery + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_2_clusters_replica_set + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_2_clusters_clusterwide + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_recover + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_recover_network_partition + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_recover_clusterwide + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_agent_flags + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_replica_set_ignore_unknown_users + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_multi_cluster_validation + tags: ["patch-run"] + exec_timeout_secs: 1000 + commands: + - func: e2e_test + +- name: e2e_om_update_before_reconciliation + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_vault_setup + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_vault_setup_tls + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_vault_setup_om + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: e2e_vault_setup_om_backup + tags: ["patch-run"] + commands: + - func: e2e_test + +- name: release_database + git_tag_only: true + commands: + - func: clone + - func: setup_building_host + - func: quay_login + - func: pipeline + vars: + image_name: database + +- name: build_om_images + commands: + - func: clone + - func: setup_building_host + - func: pipeline + vars: + image_name: ops-manager + skip_tags: release + +- name: publish_ops_manager + patch_only: true + commands: + - func: clone + - func: setup_building_host + - func: quay_login + - func: pipeline + vars: + image_name: ops-manager + include_tags: release + +- name: prepare_and_upload_openshift_bundles_for_e2e + commands: + - func: clone + - func: setup_building_host + - func: configure_docker_auth + - func: download_kube_tools + - func: setup_prepare_openshift_bundles + - func: prepare_openshift_bundles_for_e2e + +task_groups: +- name: unit_task_group + setup_group: + - func: "clone" + - func: download_kube_tools + - func: setup_shellcheck + tasks: + - lint_repo + - unit_tests + +# This is the task group that contains all the tests run in the e2e_mdb_kind_ubuntu_cloudqa build variant +- name: e2e_mdb_kind_cloudqa_task_group + max_hosts: 30 + setup_group: + - func: clone + - func: download_kube_tools + - func: setup_building_host + setup_task: + - func: cleanup_exec_environment + - func: setup_kubernetes_environment + - func: setup_cloud_qa_ubi_cloudqa + tasks: + # e2e_kube_only_task_group + - e2e_crd_validation + - e2e_replica_set_config_map + - e2e_replica_set_exposed_externally + - e2e_replica_set_pv + - e2e_replica_set_pv_multiple + - e2e_replica_set_schema_validation + - e2e_replica_set_statefulset_status + - e2e_sharded_cluster_pv + - e2e_sharded_cluster_recovery + - e2e_sharded_cluster_schema_validation + - e2e_sharded_cluster_statefulset_status + - e2e_standalone_config_map + - e2e_standalone_recovery + - e2e_standalone_schema_validation + - e2e_users_schema_validation + - e2e_replica_set_tls_default + - e2e_replica_set_tls_override + - e2e_replica_set_ignore_unknown_users + # e2e_core_task_group + - e2e_all_mongodb_resources_parallel + - e2e_multiple_cluster_failures + - e2e_replica_set_custom_podspec + - e2e_replica_set_custom_sa + - e2e_replica_set_report_pending_pods + - e2e_replica_set_recovery + - e2e_replica_set_upgrade_downgrade + - e2e_replica_set_agent_flags + - e2e_sharded_cluster + - e2e_sharded_cluster_secret + - e2e_sharded_cluster_upgrade_downgrade + - e2e_sharded_cluster_custom_podspec + - e2e_sharded_cluster_agent_flags + - e2e_standalone_type_change_recovery + - e2e_standalone_upgrade_downgrade + - e2e_standalone_custom_podspec + - e2e_standalone_agent_flags + - e2e_replica_set_process_hostnames + - e2e_replica_set_member_options + # e2e_tls_task_group + - e2e_replica_set_tls_allow + - e2e_replica_set_tls_prefer + - e2e_replica_set_tls_require_upgrade + - e2e_tls_rs_additional_certs + - e2e_tls_sc_additional_certs + - e2e_tls_rs_intermediate_ca + - e2e_tls_sharded_cluster_certs_prefix + - e2e_standalone_no_tls_no_status_is_set + - e2e_feature_controls_authentication + - e2e_replica_set + - e2e_replica_set_liveness_probe + - e2e_replica_set_mongod_options + - e2e_replica_set_readiness_probe + - e2e_replica_set_update_delete_parallel + - e2e_sharded_cluster_scale_shards + - e2e_sharded_cluster_mongod_options + - e2e_tls_rs_external_access + - e2e_replica_set_tls_require + - e2e_replica_set_tls_certs_secret_prefix + - e2e_sharded_cluster_tls_certs_top_level_prefix + - e2e_disable_tls_scale_up + - e2e_replica_set_tls_require_and_disable + # e2e_x509_task_group + - e2e_configure_tls_and_x509_simultaneously_st + - e2e_configure_tls_and_x509_simultaneously_rs + - e2e_configure_tls_and_x509_simultaneously_sc + - e2e_tls_x509_rs + - e2e_tls_x509_sc + - e2e_tls_x509_configure_all_options_rs + - e2e_tls_x509_configure_all_options_sc + - e2e_tls_x509_user_connectivity + - e2e_tls_x509_users_addition_removal + - e2e_replica_set_tls_process_hostnames + # e2e_ldap_task_group + - e2e_replica_set_ldap + - e2e_sharded_cluster_ldap + - e2e_replica_set_ldap_tls + - e2e_replica_set_ldap_user_to_dn_mapping + - e2e_replica_set_ldap_agent_auth + - e2e_replica_set_ldap_agent_client_certs + - e2e_replica_set_custom_roles + - e2e_replica_set_update_roles_no_privileges + - e2e_replica_set_ldap_group_dn + - e2e_replica_set_ldap_group_dn_with_x509_agent + # e2e_scram_sha_task_group_with_manually_generated_certs + - e2e_replica_set_scram_sha_256_user_connectivity + - e2e_replica_set_scram_sha_256_user_first + - e2e_replica_set_scram_sha_1_user_connectivity + - e2e_replica_set_scram_sha_1_upgrade + - e2e_replica_set_scram_x509_ic_manual_certs + - e2e_sharded_cluster_scram_sha_1_upgrade + - e2e_sharded_cluster_scram_sha_256_user_connectivity + - e2e_sharded_cluster_scram_sha_1_user_connectivity + - e2e_sharded_cluster_scram_x509_ic_manual_certs + # e2e_auth_transitions_task_group + - e2e_replica_set_scram_sha_and_x509 + - e2e_replica_set_x509_to_scram_transition + - e2e_sharded_cluster_scram_sha_and_x509 + - e2e_sharded_cluster_x509_to_scram_transition + - e2e_sharded_cluster_internal_cluster_transition + # e2e_webhook_validation_task_group + - e2e_mongodb_validation_webhook + - e2e_mongodb_roles_validation_webhook + - e2e_vault_setup + - e2e_vault_setup_tls + teardown_task: + - func: upload_e2e_logs + - func: teardown_kubernetes_environment + - func: teardown_cloud_qa_ubi_cloudqa + teardown_group: + - func: prune_docker_resources + - func: run_retry_script + +# This task group mostly duplicates tests running in e2e_mdb_kind_ubi_cloudqa +# This is mostly a smoke test, so we execute only a few essential ones. +- name: e2e_mdb_openshift_ubi_cloudqa_task_group + # OM tests are also run on the same Openshift cluster, so we use 1 max host to not + # allow the helm installations to interfere with eachother during the setup of the tests. + max_hosts: 1 + setup_group: + - func: clone + - func: download_kube_tools + setup_task: + - func: cleanup_exec_environment + - func: setup_kubernetes_environment + - func: setup_cloud_qa + tasks: + - e2e_crd_validation + - e2e_replica_set_scram_sha_256_user_connectivity + - e2e_sharded_cluster_scram_sha_256_user_connectivity + - e2e_replica_set_pv + - e2e_sharded_cluster_pv + teardown_task: + - func: upload_e2e_logs + - func: teardown_kubernetes_environment + - func: teardown_cloud_qa + +# e2e_operator_task_group includes the tests for the specific Operator configuration/behavior. They may deal with +# cluster-wide resources so should be run in isolated K8s clusters only (Kind or Minikube). +- name: e2e_operator_task_group + max_hosts: 2 + setup_group: + - func: clone + - func: download_kube_tools + - func: setup_building_host + setup_task: + - func: cleanup_exec_environment + - func: setup_kubernetes_environment + - func: setup_cloud_qa + tasks: + - e2e_operator_upgrade_replica_set + - e2e_operator_upgrade_ops_manager + - e2e_operator_partial_crd + - e2e_operator_clusterwide + - e2e_operator_multi_namespaces + - e2e_operator_upgrade_appdb_tls + teardown_task: + - func: upload_e2e_logs + - func: teardown_kubernetes_environment + - func: teardown_cloud_qa + teardown_group: + - func: prune_docker_resources + - func: run_retry_script + +- name: e2e_multi_cluster_kind_task_group + max_hosts: 2 + setup_group: + - func: clone + - func: download_kube_tools + - func: setup_building_host + setup_task: + - func: cleanup_exec_environment + - func: setup_kubernetes_environment + - func: setup_cloud_qa + tasks: + - e2e_multi_cluster_replica_set + - e2e_multi_cluster_replica_set_member_options + - e2e_multi_cluster_recover + - e2e_multi_cluster_recover_clusterwide + - e2e_multi_cluster_specific_namespaces + - e2e_multi_cluster_scram + - e2e_multi_cluster_tls_with_x509 + - e2e_multi_cluster_tls_no_mesh + - e2e_multi_cluster_enable_tls + - e2e_multi_cluster_with_ldap + - e2e_multi_cluster_with_ldap_custom_roles + - e2e_multi_cluster_mtls_test + - e2e_multi_cluster_replica_set_deletion + - e2e_multi_cluster_replica_set_scale_up + - e2e_multi_cluster_scale_up_cluster + - e2e_multi_cluster_scale_up_cluster_new_cluster + - e2e_multi_cluster_replica_set_scale_down + - e2e_multi_cluster_scale_down_cluster + - e2e_multi_sts_override + - e2e_multi_cluster_tls_with_scram + - e2e_multi_cluster_upgrade_downgrade + - e2e_multi_cluster_backup_restore + - e2e_multi_cluster_backup_restore_no_mesh + - e2e_multi_cluster_disaster_recovery + - e2e_multi_cluster_multi_disaster_recovery + - e2e_multi_cluster_recover_network_partition + - e2e_multi_cluster_validation + - e2e_multi_cluster_agent_flags + - e2e_multi_cluster_replica_set_ignore_unknown_users + teardown_task: + - func: upload_e2e_logs + - func: teardown_kubernetes_environment + teardown_group: + - func: prune_docker_resources + - func: run_retry_script + +- name: e2e_multi_cluster_2_clusters_task_group + max_hosts: 2 + setup_group: + - func: clone + - func: download_kube_tools + - func: setup_building_host + setup_task: + - func: cleanup_exec_environment + - func: setup_kubernetes_environment + - func: setup_cloud_qa + tasks: + - e2e_multi_cluster_2_clusters_replica_set + - e2e_multi_cluster_2_clusters_clusterwide + - e2e_multi_cluster_s3_based_backup_restore + teardown_task: + - func: upload_e2e_logs + - func: teardown_kubernetes_environment + teardown_group: + - func: prune_docker_resources + - func: run_retry_script + +# This task group runs on Kind clusters. In theory ALL Ops Manager tests should be added here +# including the cluster-wide resources changing. Uses Cloud-qa. +- name: e2e_ops_manager_kind_only_task_group + max_hosts: 15 + setup_group: + - func: clone + - func: download_kube_tools + - func: setup_building_host + setup_task: + - func: cleanup_exec_environment + - func: setup_kubernetes_environment + - func: setup_cloud_qa + tasks: + - e2e_om_appdb_agent_flags + - e2e_om_appdb_monitoring_tls + - e2e_om_appdb_multi_change + - e2e_om_appdb_scale_up_down + - e2e_om_appdb_upgrade + - e2e_om_appdb_validation + - e2e_om_appdb_scram + - e2e_om_external_connectivity + - e2e_om_jvm_params + - e2e_om_localmode + - e2e_om_ops_manager_enable_local_mode_running_om + - e2e_om_localmode_multiple_pv + - e2e_om_weak_password + - e2e_om_ops_manager_backup_delete_sts + - e2e_om_ops_manager_backup_light + - e2e_om_ops_manager_backup_tls + - e2e_om_ops_manager_backup_s3_tls + - e2e_om_ops_manager_backup_tls_custom_ca + - e2e_om_ops_manager_backup_liveness_probe + - e2e_om_ops_manager_backup_sharded_cluster + - e2e_om_ops_manager_backup_kmip + - e2e_om_ops_manager_pod_spec + - e2e_om_ops_manager_https_enabled + - e2e_om_ops_manager_https_enabled_hybrid + - e2e_om_ops_manager_https_enabled_internet_mode + - e2e_om_ops_manager_https_enabled_prefix + - e2e_om_ops_manager_scale + - e2e_om_ops_manager_upgrade + - e2e_om_validation_webhook + - e2e_om_ops_manager_secure_config + - e2e_om_update_before_reconciliation + - e2e_om_multiple + - e2e_om_appdb_configure_all_images + - e2e_om_feature_controls + - e2e_vault_setup_om + - e2e_vault_setup_om_backup + teardown_task: + - func: upload_e2e_logs + - func: teardown_kubernetes_environment + - func: teardown_cloud_qa + teardown_group: + - func: prune_docker_resources + - func: run_retry_script + +- name: e2e_ops_manager_kind_5_0_only_task_group + max_hosts: 3 + setup_group: + - func: clone + - func: download_kube_tools + - func: setup_building_host + setup_task: + - func: cleanup_exec_environment + - func: setup_kubernetes_environment + tasks: + - e2e_om_remotemode + - e2e_om_ops_manager_backup_restore + - e2e_om_ops_manager_queryable_backup + - e2e_om_ops_manager_backup + teardown_task: + - func: upload_e2e_logs + - func: teardown_kubernetes_environment + teardown_group: + - func: prune_docker_resources + - func: run_retry_script + +# Tests features only supported on OM60 +- name: e2e_ops_manager_kind_6_0_only_task_group + max_hosts: 3 + setup_group: + - func: clone + - func: download_kube_tools + - func: setup_building_host + setup_task: + - func: cleanup_exec_environment + - func: setup_kubernetes_environment + tasks: + - e2e_om_ops_manager_prometheus + teardown_task: + - func: upload_e2e_logs + - func: teardown_kubernetes_environment + teardown_group: + - func: prune_docker_resources + - func: run_retry_script + +- name: e2e_kind_olm_group + max_hosts: 30 + setup_group: + - func: clone + - func: download_kube_tools + - func: setup_building_host + setup_task: + - func: cleanup_exec_environment + - func: setup_kubernetes_environment + - func: setup_prepare_openshift_bundles + - func: install_olm + tasks: + - e2e_olm_operator_upgrade + - e2e_olm_operator_upgrade_with_resources + teardown_task: + - func: upload_e2e_logs + - func: teardown_kubernetes_environment + teardown_group: + - func: prune_docker_resources + - func: run_retry_script + + +# This task is identical to e2e_ops_manager_kind_only_task_group, with the exception that +# it will run 2 hosts at a time, not more. In shared cluster (Kops/Openshift) this means +# that the Ops Manager tests will not overload the cluster if run in parallel builds. +# Operator upgrade tests must not be included here! +- name: e2e_ops_manager_task_group_safe + max_hosts: 2 + setup_group: + - func: clone + - func: download_kube_tools + setup_task: + - func: cleanup_exec_environment + - func: setup_kubernetes_environment + tasks: + - e2e_om_appdb_agent_flags + - e2e_om_appdb_monitoring_tls + - e2e_om_appdb_multi_change + - e2e_om_appdb_scale_up_down + - e2e_om_appdb_upgrade + - e2e_om_appdb_validation + - e2e_om_appdb_scram + - e2e_om_external_connectivity + - e2e_om_jvm_params + - e2e_om_localmode + - e2e_om_ops_manager_enable_local_mode_running_om + - e2e_om_localmode_multiple_pv + - e2e_om_weak_password + - e2e_om_ops_manager_backup + - e2e_om_ops_manager_backup_sharded_cluster + - e2e_om_ops_manager_backup_light + - e2e_om_ops_manager_pod_spec + - e2e_om_ops_manager_scale + - e2e_om_ops_manager_upgrade + - e2e_om_ops_manager_secure_config + - e2e_om_appdb_configure_all_images + teardown_task: + - func: upload_e2e_logs + - func: teardown_kubernetes_environment + - func: teardown_cloud_qa + + +buildvariants: + +## Build variants for E2E tests + +# The pattern for naming build variants for E2E tests: +# e2e___[] +# where is any of mdb|om|operator +# where is any of kind|vanilla|openshift +# where is any of ubuntu|ubi +# where denotes the OM version tested (e.g. om50, om60, cloudqa) - used only for MDB tests + +## MongoDB build variants + + +- name: e2e_mdb_kind_ubi_cloudqa + display_name: e2e_mdb_kind_ubi_cloudqa + run_on: + - ubuntu1804-large + depends_on: + - name: build_operator_ubi + variant: init_test_run + - name: build_init_database_image_ubi + variant: init_test_run + - name: build_database_image_ubi + variant: init_test_run + - name: build_test_image + variant: init_test_run + - name: build_init_appdb_images_ubi + variant: init_test_run + - name: build_init_om_images_ubi + variant: init_test_run + expansions: + <<: [ *cloud_manager_qa, *kubernetes_environment_kind ] + custom_mdb_version: *mdb_50_latest + agent_version: *agent_version60 + image_type: ubi + tasks: + - name: e2e_mdb_kind_cloudqa_task_group + +- name: e2e_mdb_openshift_ubi_cloudqa + display_name: e2e_mdb_openshift_ubi_cloudqa + depends_on: + - name: build_operator_ubi + variant: init_test_run + - name: build_init_database_image_ubi + variant: init_test_run + - name: build_database_image_ubi + variant: init_test_run + - name: build_test_image + variant: init_test_run + run_on: + - ubuntu1804-small + stepback: false + expansions: + <<: [ *cloud_manager_qa, *kubernetes_environment_openshift_4 ] + custom_mdb_version: *mdb_44_latest + agent_version: *agent_version50 + image_type: ubi + tasks: + - name: e2e_mdb_openshift_ubi_cloudqa_task_group + +## Ops Manager build variants + +# Isolated Ops Manager Tests for 5.0 version +- name: e2e_om50_kind_ubi + display_name: e2e_om50_kind_ubi + run_on: + - ubuntu1804-large + depends_on: + - name: build_om_images + variant: build_om50_images + - name: build_operator_ubi + variant: init_test_run + - name: build_test_image + variant: init_test_run + - name: build_init_om_images_ubi + variant: init_test_run + - name: build_init_database_image_ubi + variant: init_test_run + - name: build_database_image_ubi + variant: init_test_run + - name: build_init_appdb_images_ubi + variant: init_test_run + expansions: + <<: [ *kubernetes_environment_kind ] + image_type: ubi + test_mode: opsmanager + + custom_om_version: *ops_manager_50_latest + custom_om_prev_version: *ops_manager_50_prev + custom_mdb_version: *mdb_50_latest + custom_mdb_prev_version: *mdb_44_latest + agent_version: *agent_version60 + + custom_appdb_version: *ops_manager_50_appdb_version + + ops_manager_registry: 268558157000.dkr.ecr.us-east-1.amazonaws.com/images/ubi + appdb_registry: 268558157000.dkr.ecr.us-east-1.amazonaws.com/images/ubi + tasks: + - name: e2e_ops_manager_kind_only_task_group + - name: e2e_ops_manager_kind_5_0_only_task_group + +# Isolated Ops Manager Tests for 6.0 version +- name: e2e_om60_kind_ubi + display_name: e2e_om60_kind_ubi + run_on: + - ubuntu1804-large + depends_on: + - name: build_om_images + variant: build_om60_images + - name: build_operator_ubi + variant: init_test_run + - name: build_test_image + variant: init_test_run + - name: build_init_om_images_ubi + variant: init_test_run + - name: build_init_database_image_ubi + variant: init_test_run + - name: build_database_image_ubi + variant: init_test_run + - name: build_init_appdb_images_ubi + variant: init_test_run + expansions: + <<: [ *kubernetes_environment_kind ] + image_type: ubi + test_mode: opsmanager + + custom_om_version: *ops_manager_60_latest + custom_om_prev_version: *ops_manager_50_latest + custom_mdb_version: *mdb_60_latest + custom_mdb_prev_version: *mdb_50_latest + agent_version: *agent_version60 + + custom_appdb_version: *ops_manager_60_appdb_version + + ops_manager_registry: 268558157000.dkr.ecr.us-east-1.amazonaws.com/images/ubi + appdb_registry: 268558157000.dkr.ecr.us-east-1.amazonaws.com/images/ubi + tasks: + - name: e2e_ops_manager_kind_only_task_group + - name: e2e_ops_manager_kind_5_0_only_task_group + - name: e2e_ops_manager_kind_6_0_only_task_group + +- name: e2e_multi_cluster_kind + display_name: e2e_multi_cluster_kind + run_on: + - ubuntu1804-xlarge + depends_on: + - name: build_operator_ubi + variant: init_test_run + - name: build_test_image + variant: init_test_run + - name: build_init_database_image_ubi + variant: init_test_run + - name: build_database_image_ubi + variant: init_test_run + expansions: + <<: [ *cloud_manager_qa, *kubernetes_environment_multi_cluster_kind ] + image_type: ubi + agent_version: *agent_version50 + tasks: + - name: e2e_multi_cluster_kind_task_group + +- name: e2e_multi_cluster_2_clusters + display_name: e2e_multi_cluster_2_clusters + run_on: + - ubuntu1804-xlarge + depends_on: + - name: build_operator_ubi + variant: init_test_run + - name: build_test_image + variant: init_test_run + - name: build_init_database_image_ubi + variant: init_test_run + - name: build_database_image_ubi + variant: init_test_run + expansions: + <<: [ *cloud_manager_qa, *kubernetes_environment_multi_cluster_2_clusters ] + image_type: ubi + agent_version: *agent_version50 + tasks: + - name: e2e_multi_cluster_2_clusters_task_group + + +## Operator tests build variants + +- name: e2e_operator_kind_ubi_cloudqa + display_name: e2e_operator_kind_ubi_cloudqa + run_on: + - ubuntu1804-large + depends_on: + - name: build_operator_ubi + variant: init_test_run + - name: build_init_database_image_ubi + variant: init_test_run + - name: build_database_image_ubi + variant: init_test_run + - name: build_init_om_images_ubi + variant: init_test_run + - name: build_test_image + variant: init_test_run + - name: build_init_appdb_images_ubi + variant: init_test_run + expansions: + <<: [ *cloud_manager_qa, *kubernetes_environment_kind ] + image_type: ubi + custom_om_version: *ops_manager_50_latest + custom_mdb_version: *mdb_44_latest + agent_version: *agent_version50 + appdb_registry: 268558157000.dkr.ecr.us-east-1.amazonaws.com/images/ubi + tasks: + - name: e2e_operator_task_group + +- name: e2e_kind_olm_ubi + display_name: e2e_kind_olm_ubi + run_on: + - ubuntu2204-large + depends_on: + - name: build_om_images + variant: build_om60_images + - name: build_operator_ubi + variant: init_test_run + - name: build_test_image + variant: init_test_run + - name: build_init_om_images_ubi + variant: init_test_run + - name: build_init_database_image_ubi + variant: init_test_run + - name: build_database_image_ubi + variant: init_test_run + - name: build_init_appdb_images_ubi + variant: init_test_run + - name: prepare_and_upload_openshift_bundles_for_e2e + variant: init_tests_with_olm + expansions: + <<: [ *kubernetes_environment_kind ] + image_type: ubi + test_mode: opsmanager + + custom_om_version: *ops_manager_60_latest + custom_om_prev_version: *ops_manager_50_latest + custom_mdb_version: *mdb_50_latest + custom_mdb_prev_version: *mdb_44_latest + agent_version: *agent_version60 + + custom_appdb_version: *ops_manager_60_appdb_version + + ops_manager_registry: 268558157000.dkr.ecr.us-east-1.amazonaws.com/images/ubi + appdb_registry: 268558157000.dkr.ecr.us-east-1.amazonaws.com/images/ubi + tasks: + - name: e2e_kind_olm_group + +### End of build variants for E2E + +### Release build variants + +## Adds versions as supported in the supported versions Database. +## The responsibility of actually building this image/version and pushing them +## to public Docker repositories is of the periodic-build task. +- name: release + display_name: release + depends_on: + - name: release_blocker + variant: release_blocker + - name: build_operator_ubi + variant: init_test_run + - name: build_init_om_images_ubi + variant: init_test_run + - name: build_init_appdb_images_ubi + variant: init_test_run + - name: build_init_database_image_ubi + variant: init_test_run + - name: build_database_image_ubi + variant: init_test_run + run_on: + - ubuntu1804-small + expansions: + include_tags: release + tasks: + - name: release_operator + - name: release_init_appdb + - name: release_init_database + - name: release_init_ops_manager + - name: release_database + +- name: preflight_release_images + display_name: preflight_release_images + run_on: + - rhel90-small + expansions: + preflight_submit: true + tasks: + - name: preflight_release_images + +- name: preflight_release_images_check + display_name: preflight_release_images_check + run_on: + - rhel90-small + expansions: + preflight_submit: false + tasks: + - name: preflight_release_images + +- name: release_blocker + display_name: release_blocker + run_on: + - ubuntu1604-packer # Note: cheapest machine I found + tasks: + - name: release_blocker + +- name: upload_dockerfiles + display_name: upload_dockerfiles + run_on: + - ubuntu1804-small + tasks: + - name: upload_dockerfiles + +- name: init_test_run + display_name: init_test_run + run_on: + - ubuntu1804-small + stepback: false + tasks: + - name: build_operator_ubi + - name: build_test_image + + # Init AppDB images + - name: build_init_appdb_images_ubi + + - name: build_init_om_images_ubi + - name: build_init_database_image_ubi + - name: build_database_image_ubi + - name: prepare_cluster_vanilla + - name: prepare_aws + +- name: init_tests_with_olm + display_name: init_tests_with_olm + depends_on: + - name: build_om_images + variant: build_om60_images + - name: build_operator_ubi + variant: init_test_run + - name: build_test_image + variant: init_test_run + - name: build_init_om_images_ubi + variant: init_test_run + - name: build_init_database_image_ubi + variant: init_test_run + - name: build_database_image_ubi + variant: init_test_run + - name: build_init_appdb_images_ubi + variant: init_test_run + run_on: + - ubuntu2204-small + expansions: + agent_version: *agent_version60 + image_type: ubi + tasks: + - name: prepare_and_upload_openshift_bundles_for_e2e + +- name: go_unit_tests + display_name: "go_unit_tests" + run_on: + - ubuntu1804-small + stepback: false + tasks: + - name: "unit_task_group" + +### Build variants for manual patch only + +- name: build_om50_images + display_name: build_om50_images + run_on: + - ubuntu1804-small + expansions: + om_version: *ops_manager_50_latest + tasks: + - name: build_om_images + +- name: build_om60_images + display_name: build_om60_images + run_on: + - ubuntu1804-small + expansions: + om_version: *ops_manager_60_latest + tasks: + - name: build_om_images + +- name: publish_om50_images + display_name: publish_om50_images + run_on: + - ubuntu1804-small + depends_on: + - variant: e2e_om50_kind_ubi + name: '*' + expansions: + om_version: *ops_manager_50_latest + preflight_submit: true + tasks: + - name: publish_ops_manager + +- name: preflight_om50_images + display_name: preflight_om50_images + run_on: + - rhel90-small + expansions: + image_version: *ops_manager_50_latest + preflight_submit: true + tasks: + - name: preflight_om_image + + +- name: publish_om60_images + display_name: publish_om60_images + run_on: + - ubuntu1804-small + depends_on: + - variant: e2e_om60_kind_ubi + name: '*' + expansions: + om_version: *ops_manager_60_latest + preflight_submit: true + tasks: + - name: publish_ops_manager + +- name: preflight_om60_images + display_name: preflight_om60_images + run_on: + - rhel90-small + expansions: + image_version: *ops_manager_60_latest + preflight_submit: true + tasks: + - name: preflight_om_image diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 000000000..e0fb7f908 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,141 @@ +#!/bin/bash + +set -Eeou pipefail +set -x + +if [[ -z "${EVERGREEN_MODE:-}" ]]; then + git_last_changed=$(git diff --cached --name-only --diff-filter=ACM) +else + git_last_changed=$(git diff --cached --name-only --diff-filter=ACM origin/master) +fi + +mkdir -p "$(go env GOPATH)/bin" + +# Generates a yaml file to install the operator from the helm sources. +function generate_standalone_yaml() { + HELM_OPTS=$@ + + charttmpdir=$(mktemp -d 2>/dev/null || mktemp -d -t 'charttmpdir') + charttmpdir=${charttmpdir}/chart + mkdir -p "${charttmpdir}" + + helm template --namespace mongodb -f helm_chart/values.yaml helm_chart --output-dir "${charttmpdir}" ${HELM_OPTS[@]} + + cat "${charttmpdir}/enterprise-operator/templates/"{operator-roles.yaml,database-roles.yaml,operator.yaml} >public/mongodb-enterprise.yaml + + cat "helm_chart/crds/"* >public/crds.yaml + + rm -rf "${charttmpdir:?}/*" + + helm template --namespace mongodb -f helm_chart/values.yaml helm_chart --output-dir "${charttmpdir}" --values helm_chart/values-openshift.yaml ${HELM_OPTS[@]} + + cat "${charttmpdir}/enterprise-operator/templates/"{operator-roles.yaml,database-roles.yaml,operator.yaml} >public/mongodb-enterprise-openshift.yaml +} + +function black_formatting() { + # installing Black + if ! command -v "black" >/dev/null; then + pip3 install -r docker/mongodb-enterprise-tests/requirements-dev.txt + fi + + # Black formatting of every python file that was changed + for file in $(echo "$git_last_changed" | grep '\.py$'); do + black -q "$file" + git add "$file" + done +} + +function update_values_yaml_files() { + # ensure that all helm values files are up to date. + # shellcheck disable=SC2154 + if [[ -z "${workdir:-}" ]]; then + scripts/evergreen/release/update_helm_values_files.py + else + "${workdir}"/venv/bin/python scripts/evergreen/release/update_helm_values_files.py + fi + + # commit any changes we made + git add helm_chart/values.yaml + git add helm_chart/values-openshift.yaml + + # these can change if the version of community operator is different + git add go.mod + git add go.sum +} + +function update_release_json() { + # ensure that release.json is up 2 date + # shellcheck disable=SC2154 + if [[ -z "${workdir:-}" ]]; then + scripts/evergreen/release/update_release.py + else + "${workdir}"/venv/bin/python scripts/evergreen/release/update_release.py + fi + + # commit any changes we made + git add release.json +} + +function pre_commit() { + # Update release.json first in case there is a newer version + update_release_json + # We need to generate the values files first + update_values_yaml_files + # The values files are used for generating the standalone yaml + generate_standalone_yaml + # Run black on python files that have changed + black_formatting + + source scripts/evergreen/lint_code.sh + + if echo "$git_last_changed" | grep -q 'go.mod'; then + echo 'regenerating licenses.csv' + scripts/evergreen/update_licenses.sh + git add licenses.csv + fi + + if echo "$git_last_changed" | grep -q 'public/tools/multicluster'; then + echo 'regenerating multicluster RBAC public example' + pushd public/tools/multicluster + EXPORT_RBAC_SAMPLES="true" go test -run TestPrintingOutRolesServiceAccountsAndRoleBindings + popd + git add public/samples/multi-cluster-cli-gitops + fi + + if find . -name "Makefile" | grep -v vendor | xargs grep "\${"; then + echo 'ERROR: Makefiles should NEVER contain curly brackets variables' + exit 1 + fi + + # Makes sure there are not erroneous kubebuilder annotations that can + # end up in CRDs as descriptions. + if grep "// kubebuilder" ./* -r --exclude-dir=vendor --include=\*.go; then + echo "Found an erroneous kubebuilder annotation" + exit 1 + fi + + # run shellcheck on all modified shell scripts + for file in $(echo "$git_last_changed" | grep -v '\.go$' | grep -v '\.py' | grep -v '\.yaml' | grep -v '\.json'); do + # check if bash script + if head -1 "${file}" | grep "#!/usr/bin/env bash" >/dev/null; then + # see https://vaneyckt.io/posts/safer_bash_scripts_with_set_euxo_pipefail/ + if ! grep "set -Eeou pipefail" "${file}" >/dev/null; then + echo "set opts not set on ${file}" + exit 1 + fi + if ! shellcheck -x "${file}"; then + echo "shellcheck failed on ${file}" + exit 1 + fi + fi + done +} + +cmd=${1:-"pre-commit"} + +if [[ "${cmd}" == "generate_standalone_yaml" ]]; then + shift 1 + generate_standalone_yaml "$@" +elif [[ "${cmd}" == "pre-commit" ]]; then + pre_commit +fi diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..c468ad84e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +* @irajdeep @mircea-cosbuc @lsierant @slaskawi @nammn + +helm_chart/crds/ @dan-mckean diff --git a/.github/PULL_REQUEST_TEMPLATE/internal_change.md b/.github/PULL_REQUEST_TEMPLATE/internal_change.md new file mode 100644 index 000000000..0336bf54a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/internal_change.md @@ -0,0 +1,3 @@ +# Summary + +*Enter your issue summary here.* diff --git a/.github/PULL_REQUEST_TEMPLATE/release.md b/.github/PULL_REQUEST_TEMPLATE/release.md new file mode 100644 index 000000000..8686ddcfa --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/release.md @@ -0,0 +1,44 @@ +# Release + +*For in-depth information of how to perform any of these steps, please read +refer to [how-to-release](../../docs/dev/release/how-to-release.md) document.* + +# Pre-merging tasks + +*We'll make sure all the following tasks are completed before merging.* + +## Project Tasks + +- [ ] Update any finished ticket's `Fix Version` to this version. +- [ ] Prepare release notes in [public repo](https://github.com/mongodb/mongodb-enterprise-kubernetes/releases/new). +- [ ] Prepare release notes in [DOCSP](https://jira.mongodb.org/secure/CreateIssueDetails!init.jspa?pid=14181&issuetype=3&summary=[MEKO]%20Kubernetes%20Enterprise%20Operator%20x.y.z%20Release%20Notes). + +## Versioning + +- [ ] Ensure that all of versions in release.json are correct and are properly reflected in `values.yaml` and `values-openshift.yaml`. + +## Public repo + +- [ ] All public samples are up-to-date. + +# Post-merging tasks + +*After merging this PR make sure you complete the following tasks.* + +- [ ] Unblock relevant release tasks from Evergreen's Waterfall. +- [ ] Update public repo contents. + +## Openshift/RedHat Changes + +*Refer to +[publishing-to-marketplaces.md](docs/dev/release/publishing-to-marketplaces.md) +for more infomation on how to do this* + +- [ ] New version has been pushed to Operatorhub.io. +- [ ] New version has been pushed to Openshift Marketplace. + +# Following day + +- [ ] Check [#k8s-operator-daily-builds](https://mongodb.slack.com/archives/C01HYH2KUJ1) to see if this version was pushed correctly. +- [ ] Publish public repo tag/release. +- [ ] Ask DOCS team to publish Release Notes. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..533f5d742 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: "/" + schedule: + interval: weekly + day: monday + reviewers: + - "irajdeep" + - "mircea-cosbuc" + - "priyolahiri" + - "lsierant" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..8ad2c3ee5 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,28 @@ +# Summary + +*Enter your issue summary here.* + +## Documentation changes + +* [ ] Add an entry to [release notes](.../RELEASE_NOTES.md). +* [ ] When needed, make sure you create a new [DOCSP ticket](https://jira.mongodb.org/projects/DOCSP) that documents your change. + +## Changes to CRDs + +* [ ] Add `@jamesbroadhead` (James) and `@priyolahiri` (Priyo) as reviewers. +* [ ] Make sure any changes are reflected on `/public/samples` directory. + +## If this PR introduces any change to the Kubernetes or Ops Manager APIs or core reconciliation logic. + +* [ ] Make sure you add extensive E2E test specially to the creation and updates of the new resources. +* [ ] Your object definitions should adhere to [Kubernetes Object Names and IDs](https://kubernetes.io/docs/concepts/overview/working-with-objects/names/) conventions. + +## If this PR touches existing functions whose docstrings need modifications +* [ ] Make sure to correct the docstring as per [The Go Blog](https://blog.golang.org/godoc). +* [ ] Ensure there are no stale comments. + +## If this PR includes anything that will require modifying the CSVs. +E.g. changes to env vars, images or operator permissions. + +* [ ] Make sure you have updated [csv changes](../docs/dev/release/csv-changes.md) + diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..de7b9d81f --- /dev/null +++ b/.gitignore @@ -0,0 +1,72 @@ +.dir-locals.el +.idea/ + +/pkg/client +/vendor/ +docker/mongodb-enterprise-operator/content/mongodb-enterprise-operator +docker/mongodb-enterprise-database/content/mongodb-mms-automation-agent-version.properties +docker/mongodb-enterprise-database/content/readinessprobe +docker/mongodb-enterprise-ops-manager/scripts/mmsconfiguration + +my-* +.vscode +.env +data +cache +**/__pycache__ +**/myenv +**/venv*/ +**/env*/ +*.bak +**/pytest_cache/ +bin/* +**/*.iml +helm_out +.redo-last-namespace +exports.do + +# These files get generated by emacs and sometimes they are still present when committing +**/flycheck_* + +public/support/*.gz +public/support/logs* +docker/mongodb-enterprise-appdb/content/readinessprobe +ops-manager-kubernetes +docker/mongodb-enterprise-operator/Dockerfile +docker/mongodb-enterprise-database/Dockerfile +docker/mongodb-enterprise-ops-manager/Dockerfile +docker/mongodb-enterprise-init-database/Dockerfile +docker/mongodb-enterprise-init-ops-manager/Dockerfile +docker/mongodb-enterprise-operator/content/mongodb-enterprise-operator.tar +docker/mongodb-enterprise-tests/helm_chart/ +.shellcheckrc + +bundle + +.DS_Store +cover.out +ops-manager-kubernetes.suite +# loadtesting binary +production_notes/cmd/runtest/runtest +production_notes/helm_charts/opsmanager/charts/ +multi_cluster/examples/secret_mirror/secret_mirror +multi_cluster/examples/service_account/service_account +multi_cluster/examples/service_account_using_go/service_account_using_go + +_.yaml + +# name of binary that is built for the e2e tests +multi-cluster-kube-config-creator +istio-* +.multi_cluster_local_test_files + +# we don't ever want to save this file. It is used during `make bundle` to generate the csv. +config/manager/manager.yaml + +# ignore symlink to ~/.operator-dev +.operator-dev +tmp + +licenses_full.csv +licenses_stderr +docker/mongodb-enterprise-tests/.test_identifiers* diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..2b693d684 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "deploy/helm-charts"] + path = deploy/helm-charts + url = https://github.com/mongodb/helm-charts diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..57ddfb233 --- /dev/null +++ b/Makefile @@ -0,0 +1,366 @@ +SHELL := /bin/bash + +all: manager + +export MAKEFLAGS="-j 16" # enable parallelism + +usage: + @ echo "Development utility to work with Operator on daily basis. Just edit your configuration in '~/.operator-dev/contexts', " + @ echo "switch to it using 'make switch', make sure Ops Manager is running (use 'make om') " + @ echo "and call 'make' to start the Kubernetes cluster and the Operator" + @ echo + @ echo "More information can be found by the link https://github.com/10gen/ops-manager-kubernetes/blob/master/docs/dev/dev-start-guide.md" + @ echo + @ echo "Usage:" + @ echo " prerequisites: installs the command line applications necessary for working with this tool and adds git pre-commit hook." + @ echo " init: prepares operator environment." + @ echo " switch: switch current dev context, e.g 'make switch context=kops'. Note, that it switches" + @ echo " kubectl context as well and sets the current namespace to the one configured as the default" + @ echo " one" + @ echo " contexts: list all available contexts" + @ echo " operator: build and push Operator image, deploy it to the Kubernetes cluster" + @ echo " Use the 'debug' flag to build and deploy the Operator in debug mode - you need" + @ echo " to ensure the 30042 port on the K8s node is open" + @ echo " Use the 'watch_namespace' flag to specify a namespace to watch or leave empty to watch project namespace." + @ echo " database: build and push Database image" + @ echo " full: ('make' is an alias for this command) ensures K8s cluster is up, cleans Kubernetes" + @ echo " resources, build-push-deploy operator, push-deploy database, create secrets, " + @ echo " config map, resources etc" + @ echo " appdb: build and push AppDB image. Specify 'om_version' in format '4.2.1' to provide the already released Ops Manager" + @ echo " version which will be used to find the matching tag and find the Automation Agent version. Add 'om_branch' " + @ echo " if Ops Manager is not released yet and you want to have some git branch as the source " + @ echo " parameters in ~/operator-dev/om" + @ echo " reset: cleans all Operator related state from Kubernetes and Ops Manager. Pass the 'light=true'" + @ echo " to perform a \"light\" cleanup - delete only Mongodb resources" + @ echo " e2e: runs the e2e test, e.g. 'make e2e test=e2e_sharded_cluster_pv'. The Operator is redeployed before" + @ echo " the test, the namespace is cleaned. The e2e app image is built and pushed. Use a 'light=true'" + @ echo " in case you are developing tests and not changing the application code - this will allow to" + @ echo " avoid rebuilding Operator/Database/Init images. Use 'debug=true' to run operator in debug mode." + @ echo " Use a 'local=true' to run the test locally using 'pytest'." + @ echo " Use a 'skip=true' to skip cleaning resources (this may help developing long-running tests like for Ops Manager)" + @ echo " Sometimes you may need to pass some custom configuration, this can be done this way:" + @ echo " make e2e test=e2e_om_ops_manager_upgrade custom_om_version=4.2.8" + @ echo " recreate-e2e-kops: deletes and creates a specified e2e cluster 'cluster' using kops (note, that you don't need to switch to the correct" + @ echo " kubectl context - the script will handle everything). Pass the flag 'imsure=yes' to make it work." + @ echo " Pass 'cluster' parameter for a cluster name if it's different from default ('e2e.mongokubernetes.com')" + @ echo " Possible values are: 'e2e.om.mongokubernetes.com', 'e2e.multinamespace.mongokubernetes.com'" + @ echo " recreate-e2e-openshift: deletes and creates an e2e Openshift cluster" + @ echo " recreate-e2e-multicluster-kind Recreates local (Kind-based) development environment for running tests" + @ echo " log: reads the Operator log" + @ echo " status: prints the current context and the state of Kubernetes cluster" + @ echo " dashboard: opens the Kubernetes dashboard. Make sure the cluster was installed using current Makefile as" + @ echo " dashboard is not installed by default and the script ensures it's installed and permissions" + @ echo " are configured." + @ echo " open-automation-config/ac: displays the contents of the Automation Config in in $EDITOR using ~/.operator-dev configuration" + + +# install all necessary software, must be run only once +prerequisites: + @ scripts/dev/install.sh + +# prepare default configuration context files +init: + @ mkdir -p ~/.operator-dev/contexts + @ cp -n scripts/dev/samples/* ~/.operator-dev/contexts || true + @ echo "Initialized dev environment (~/.operator-dev)" + @ make switch context=dev + +switch: + @ scripts/dev/switch_context.sh $(context) + +# prints all current contexts +contexts: + @ scripts/dev/print_contexts + +# builds the Operator binary file and docker image and pushes it to the remote registry if using a remote registry. Deploys it to +# k8s cluster +operator: configure-operator build-and-push-operator-image + @ $(MAKE) deploy-operator + +# build-push, (todo) restart database +database: aws_login + @ ./pipeline.py --include database + +# ensures cluster is up, cleans Kubernetes + OM, build-push-deploy operator, +# push-deploy database, create secrets, config map, resources etc +full: ensure-k8s-and-reset build-and-push-images + @ $(MAKE) deploy-and-configure-operator + @ scripts/dev/apply_resources + +# build-push appdb image +appdb: aws_login + @ ./pipeline.py --include appdb + +log: + @ . scripts/dev/read_context.sh + @ kubectl logs -f deployment/mongodb-enterprise-operator --tail=1000 + +# runs the e2e test: make e2e test=e2e_sharded_cluster_pv. The Operator is redeployed before the test, the namespace is cleaned. +# The e2e test image is built and pushed together with all main ones (operator, database, init containers) +# Use 'light=true' parameter to skip images rebuilding - use this mode when you are focused on e2e tests development only +e2e: build-and-push-test-image + @ if [[ -z "$(skip)" ]]; then \ + $(MAKE) reset; \ + fi + @ if [[ -z "$(light)" ]]; then \ + $(MAKE) build-and-push-images; \ + fi + @ scripts/dev/launch_e2e.sh + +e2e-telepresence: build-and-push-test-image + telepresence connect --context $(test_pod_cluster); scripts/dev/launch_e2e.sh; telepresence quit + +# deletes and creates a kops e2e cluster +recreate-e2e-kops: + @ scripts/dev/recreate_e2e_kops.sh $(imsure) $(cluster) + +# TODO: Automate this process +# deletes and creates a openshift e2e cluster +recreate-e2e-openshift: + @ echo "Please follow instructions in docs/openshift4.md to install the Openshift4 cluster." + +# clean all kubernetes cluster resources and OM state. "light=true" to clean only Mongodb resources +reset: + @ scripts/dev/reset.sh $(light) + +status: + @ scripts/dev/status + +# opens the automation config in your editor +open-automation-config: ac +ac: + @ scripts/dev/print_automation_config + +############################################################################### +# Internal Targets +# These won't do anything bad if you call them, they just aren't the ones that +# were designed to be helpful by themselves. Anything below won't be documented +# in the usage target above. +############################################################################### + +# dev note on '&> /dev/null || true': if the 'aws_login' is run in parallel (e.g. 'make' launches builds for images +# in parallel and both call 'aws_login') then Docker login may return an error "Error saving credentials:..The +# specified item already exists in the keychain". Seems this allows to ignore the error +aws_login: + @ . scripts/dev/set_env_context.sh; scripts/dev/configure_docker_auth.sh + +build-and-push-operator-image: aws_login + @ ./pipeline.py --include operator-quick + +build-and-push-database-image: aws_login + @ scripts/dev/build_push_database_image + +build-and-push-test-image: aws_login build-multi-cluster-binary + @ if [[ -z "$(local)" ]]; then \ + ./pipeline.py --include test; \ + fi + +build-multi-cluster-binary: + scripts/evergreen/build_multi_cluster_kubeconfig_creator.sh + +# builds all app images in parallel +# note that we cannot build both appdb and database init images in parallel as they change the same docker file +build-and-push-images: build-and-push-operator-image appdb-init-image om-init-image database + @ $(MAKE) database-init-image + +database-init-image: + @ ./pipeline.py --include init-database + +appdb-init-image: + @ ./pipeline.py --include init-appdb + +om-init-image: + @ ./pipeline.py --include init-ops-manager + +deploy-operator: + @ scripts/dev/deploy_operator.sh $(debug) + +configure-operator: + @ scripts/dev/configure_operator.sh + +deploy-and-configure-operator: deploy-operator configure-operator + +ensure-k8s: + @ scripts/dev/ensure_k8s.sh + +cert: + @ openssl req -nodes -new -x509 -keyout ca-tls.key -out ca-tls.crt -extensions v3_ca -days 3650 + @ mv ca-tls.key ca-tls.crt docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/ + @ cat docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/ca-tls.crt \ + docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/mongodb-download.crt \ + > docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/ca-tls-full-chain.crt + +ensure-k8s-and-reset: ensure-k8s + @ $(MAKE) reset + +.PHONY: recreate-e2e-multicluster-kind +recreate-e2e-multicluster-kind: + scripts/dev/recreate_kind_clusters.sh + +#################################### +## operator-sdk provided Makefile ## +#################################### +# +# The next section is the Makefile provided by operator-sdk. +# We'll start moving to use some of the targets provided by it, like +# `manifests` and `bundle`. For now we'll try to maintain both. + + +# VERSION defines the project version for the bundle. +# Update this value when you upgrade the version of your project. +# To re-generate a bundle for another specific version without changing the standard setup, you can: +# - use the VERSION as arg of the bundle target (e.g make bundle VERSION=0.0.2) +# - use environment variables to overwrite this value (e.g export VERSION=0.0.2) +VERSION ?= 1.15.0 + +# EXPIRES sets a label to expire images (quay specific) +EXPIRES := --label quay.expires-after=48h + +# CHANNELS define the bundle channels used in the bundle. +# Add a new line here if you would like to change its default config. (E.g CHANNELS = "preview,fast,stable") +# To re-generate a bundle for other specific channels without changing the standard setup, you can: +# - use the CHANNELS as arg of the bundle target (e.g make bundle CHANNELS=preview,fast,stable) +# - use environment variables to overwrite this value (e.g export CHANNELS="preview,fast,stable") +ifneq ($(origin CHANNELS), undefined) +BUNDLE_CHANNELS := --channels=$(CHANNELS) +endif + +# DEFAULT_CHANNEL defines the default channel used in the bundle. +# Add a new line here if you would like to change its default config. (E.g DEFAULT_CHANNEL = "stable") +# To re-generate a bundle for any other default channel without changing the default setup, you can: +# - use the DEFAULT_CHANNEL as arg of the bundle target (e.g make bundle DEFAULT_CHANNEL=stable) +# - use environment variables to overwrite this value (e.g export DEFAULT_CHANNEL="stable") +ifneq ($(origin DEFAULT_CHANNEL), undefined) +BUNDLE_DEFAULT_CHANNEL := --default-channel=$(DEFAULT_CHANNEL) +endif +BUNDLE_METADATA_OPTS ?= $(BUNDLE_CHANNELS) $(BUNDLE_DEFAULT_CHANNEL) + +# BUNDLE_IMG defines the image:tag used for the bundle. +# You can use it as an arg. (E.g make bundle-build BUNDLE_IMG=/:) +BUNDLE_IMG ?= controller-bundle:$(VERSION) + +# Image URL to use all building/pushing image targets +IMG ?= mongodb-enterprise-operator:latest +# Produce CRDs that work back to Kubernetes 1.11 (no version conversion) +CRD_OPTIONS ?= "crd" + +# Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) +ifeq (,$(shell go env GOBIN)) +GOBIN=$(shell go env GOPATH)/bin +else +GOBIN=$(shell go env GOBIN) +endif + + +# Run tests +ENVTEST_ASSETS_DIR=$(shell pwd)/testbin +test: generate fmt vet manifests + scripts/evergreen/unit-tests.sh + +# Build manager binary +manager: generate fmt vet + GOOS=linux GOARCH=amd64 go build -o docker/mongodb-enterprise-operator/content/mongodb-enterprise-operator main.go + +# Run against the configured Kubernetes cluster in ~/.kube/config +run: generate fmt vet manifests + go run ./main.go + +# Install CRDs into a cluster +install: manifests kustomize + $(KUSTOMIZE) build config/crd | kubectl apply -f - + +# Uninstall CRDs from a cluster +uninstall: manifests kustomize + $(KUSTOMIZE) build config/crd | kubectl delete -f - + +# Deploy controller in the configured Kubernetes cluster in ~/.kube/config +deploy: manifests kustomize + cd config/manager && $(KUSTOMIZE) edit set image controller=$(IMG) + $(KUSTOMIZE) build config/default | kubectl apply -f - + +# UnDeploy controller from the configured Kubernetes cluster in ~/.kube/config +undeploy: + $(KUSTOMIZE) build config/default | kubectl delete -f - + +# Generate manifests e.g. CRD, RBAC etc. +manifests: controller-gen + $(CONTROLLER_GEN) $(CRD_OPTIONS) rbac:roleName=manager-role paths=./... output:crd:artifacts:config=config/crd/bases + # copy the CRDs to the public folder + cp config/crd/bases/* helm_chart/crds/ + cat "helm_chart/crds/"* > public/crds.yaml + + +# Run go fmt against code +fmt: + go fmt ./... + +# Run go vet against code +vet: + go vet ./... + +# Generate code +generate: controller-gen + $(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./..." + +# Build the docker image +docker-build: test + docker build -t $(IMG) . + +# Push the docker image +docker-push: + docker push $(IMG) + +# Download controller-gen locally if necessary +CONTROLLER_GEN = $(shell pwd)/bin/controller-gen +controller-gen: + $(call go-install-tool,$(CONTROLLER_GEN),sigs.k8s.io/controller-tools/cmd/controller-gen@v0.10.0) + +# Download kustomize locally if necessary +KUSTOMIZE = $(shell pwd)/bin/kustomize +kustomize: + $(call go-install-tool,$(KUSTOMIZE),sigs.k8s.io/kustomize/kustomize/v4@v4.5.4) + +# go-install-tool will 'go get' any package $2 and install it to $1. +PROJECT_DIR := $(shell dirname $(abspath $(lastword $(MAKEFILE_LIST)))) +define go-install-tool +@[ -f $(1) ] || { \ +set -e ;\ +TMP_DIR=$$(mktemp -d) ;\ +cd $$TMP_DIR ;\ +go mod init tmp ;\ +echo "Downloading $(2)" ;\ +GOBIN=$(PROJECT_DIR)/bin go install $(2) ;\ +rm -rf $$TMP_DIR ;\ +} +endef + +# Generate bundle manifests and metadata, then validate generated files. +.PHONY: bundle +bundle: manifests kustomize + # we want to create a file that only has the deployment. Note: this will not work if something is added + # after the deployment in openshift.yaml + rm config/manager/manager.yaml || true + + # when generating the CSV, the expected location of the deployment is under config/manager. This isn't + # where the actual file lives so we temporarily copy it there for the CSV generation to succeed. + cat public/mongodb-enterprise-openshift.yaml | grep -A 1000 -B 1 Deployment > config/manager/manager.yaml + operator-sdk generate kustomize manifests -q + cd config/manager && $(KUSTOMIZE) edit set image controller=$(IMG) + $(KUSTOMIZE) build config/manifests | operator-sdk generate bundle -q --overwrite --version $(VERSION) $(BUNDLE_METADATA_OPTS)\ + --channels=stable --default-channel=stable\ + --output-dir ./bundle/$(VERSION)/ + operator-sdk bundle validate ./bundle/$(VERSION) + + +# Build the bundle image. +.PHONY: bundle-build +bundle-build: + docker build $(EXPIRES) --platform linux/amd64 -f ./bundle/$(VERSION)/bundle.Dockerfile -t $(BUNDLE_IMG) . + +.PHONY: dockerfiles +dockerfiles: + python scripts/update_supported_dockerfiles.py + tar -czvf ./public/dockerfiles-$(VERSION).tgz ./public/dockerfiles + +prepare-local-e2e: # prepares the local environment to run a local operator + scripts/dev/prepare_local_e2e_run.sh diff --git a/PROJECT b/PROJECT new file mode 100644 index 000000000..c76140b76 --- /dev/null +++ b/PROJECT @@ -0,0 +1,36 @@ +domain: mongodb.com +layout: go.kubebuilder.io/v3 +projectName: mongodb-enterprise +repo: github.com/10gen/ops-manager-kubernetes +resources: +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: mongodb.com + group: mongodb + kind: MongoDB + path: github.com/10gen/ops-manager-kubernetes/api/v1 + version: v1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: mongodb.com + group: mongodb + kind: MongoDBOpsManager + path: github.com/10gen/ops-manager-kubernetes/api/v1 + version: v1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: mongodb.com + group: mongodb + kind: MongoDBUser + path: github.com/10gen/ops-manager-kubernetes/api/v1 + version: v1 +version: "3" +plugins: + manifests.sdk.operatorframework.io/v2: {} + scorecard.sdk.operatorframework.io/v2: {} diff --git a/README.md b/README.md new file mode 100644 index 000000000..ea4f62a10 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# Ops Manager Operator # + + + +This is a Kubernetes Operator (https://coreos.com/operators/) to work +with Ops Manager and Kubernetes clusters. It allows to easily add new +MongoDB deployments (standalones, replica sets, sharded clusters) to your Kubernetes cluster, configure them (modify, scale up/down, remove) and to manage them from your +Ops Manager installation. This provides combined power of Kubernetes (native scheduling of applications to nodes, scaling, fault tolerance etc) with Ops Manager capabilities (monitoring, backup, upgrades etc) + +## High-level + +The high-level picture for the process of installing Mongodb deployment into Kubernetes cluster is as follows: +* admin creates the `mongodb-enterprise-operator` Kubernetes Deployment which contains the operator application from config `mongodb-enterprise-operator.yaml`. This is a one-time operation. +* admin creates custom MongoDB objects in Kubernetes (`MongoDB`). For example is `kubectl apply -f my-replicaset.yaml` +* `mongodb-enterprise-operator` watches these changes and applies them to different participants: + * creates the Kubernetes StatefulSet(s) containing containers with automation agent binaries. They will be responsible for installation and managing local mongod process. + * applies changes to the Ops Manager automation config using public API. So the deployment object (OM replica set for example) will be created and displayed in Ops Manager Deployment UI. +These changes will be propagated back to the automation agents sitting in the pods and they will do all the work of downloading and launching MongoDB binaries locally in the same container. + +The update process follows the same approach in general except for no new objects are created in Kubernetes and OpsManager but current existing ones are updated. The example of such modification is scaling down/up of a replica set which will remove/add pods to the StatefulSet and remove/add members to the replica set in Ops Manager + +## Installation ## + +### Prerequisites ### + +* Kubernetes cluster. The easiest way is to install [Minikube](docs/minikube.md) locally. + Another way is to use [Kops](docs/aws_kops.md) to deploy cluster in AWS + + *Hint:* as all Kubernetes objects are created in `mongodb` namespace it makes sense to set this namespace as the default one + using the command `kubectl config set-context $(kubectl config current-context) --namespace=mongodb`. After + this all `kubectl` commands will work for the resources in this namespace by default unless you override it using `-n ` syntax + +* Ops Manager / Cloud Manager. To spin up Ops Manager you can use [mci](https://mci.mms-build.10gen.cc). Check more detailed +[instructions](docs/ops-manager.md) about how to enable public API access to Ops Manager. + +### Installing Operator from Docker repositories ### + +If you want just to **run** the Ops Manager Kubernetes Operator - you don't need to compile/build artifacts as +you can use prebuilt images. +* To install an official release of Operator please follow the instructions in +[mongodb-enterprise-kubernetes](https://github.com/mongodb/mongodb-enterprise-kubernetes) repository (which is the public +repository containing helm charts and yaml files to install official version of `mongodb-enterprise-operator`) +* To install latest image built from master branch you can follow the [Helm instructions for installing dev builds](/docs/helm.md). + +### Building and Installing Operator from source code ### + +Check the [link](docs/dev/dev-start-guide.md) to learn the development workflow for the Operator. + +## Create your first managed MongoDB Object ## + +The following data from Ops Manager is necessary to configure Kubernetes Operator to communicate with Ops Manager +* User login with sufficient privileges +* Public API Key +* Group Name +* Org ID (Optional) +* Base URL + +With this you will need to create two Kubernetes objects: a `Project`, which is a logical +agrupation of MongoDB objects in Ops/Cloud Manager, and `Credentials`, +which contain information about the users API Keys needed to perform +actions in Ops/Cloud Manager. + +Please refer to [this link](docs/using-credentials.md) for complete +documentation on how to do this. + +After this use any of files in `samples/minimal` folder to create a standalone/replica set/sharded cluster. +For example to create replica set execute the following command: + +```bash +kubectl apply -f samples/om-replica-set.yaml +``` + +After invoking this command a set of Kubernetes resources will be created and Ops Manager will display the new replica +set on "Deployment" page + +(`samples/minimal` contains the simplest configurations to start with. To see the complete configurations possible with some + descriptions check the `/samples/extended` folder) + +If you don't see a "green" replica set in Ops Manager then you need to check [troubleshooting](docs/troubleshooting.md) +page for steps that should be taken to diagnose the problem. + +## Development + +### Getting started +Please see the [starting guide](docs/dev/dev-start-guide.md). + +### Dev Clusters +We use `kops` utility to provision and manage Kubernetes clusters. We have one shared environment for development +`dev.mongokubernetes.com` and each developer can create their own clusters. Usual practice is start from 3 EC2 nodes +and extend them to bigger number only if necessary. + +More on working with `kops` is [here](docs/aws_kops.md) + +### Docker ECR Registry +Docker images are published to Elastic Container Registry `268558157000.dkr.ecr.us-east-1.amazonaws.com` where a +specific repository is created for each of namespace/application combinations. For example `dev` versions of agent and +operator reside in `268558157000.dkr.ecr.us-east-1.amazonaws.com/dev/mongodb-enterprise-database` and +`268558157000.dkr.ecr.us-east-1.amazonaws.com/dev/mongodb-enterprise-operator`. + +More on how to work with ECR is [here](docs/dev/aws_docker_registry.md) + +We also use `quay.io` private and public repositories + +# Release process + +The release process is described [here](docs/release.md). + diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 000000000..0637b5a14 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,555 @@ +*(Please use the [release template](docs/dev/release/release-notes-template.md) as the template for this document)* + + +# MongoDB Enterprise Kubernetes Operator 1.20.0 + +# MongoDBOpsManager Resource +* Added support for votes, priority and tags by introducing the `spec.applicationDatabase.memberConfig.votes`, `spec.applicationDatabase.memberConfig.priority` +and `spec.applicationDatabase.memberConfig.tags` field. +* Introduced automatic change of the AppDB's image version suffix `-ent` to `-ubi8`. + * This enables migration of AppDB images from the legacy repository (`quay.io/mongodb/mongodb-enterprise-appdb-database-ubi`) to the new official one (`quay.io/mongodb/mongodb-enterprise-server`) without changing the version in MongoDBOpsManager's `applicationDatabase.version` field. + * The change will result a rolling update of AppDB replica set pods to the new, official images (referenced in Helm Chart in `values.mongodb.name` field), which are functionally equivalent to the previous ones (the same MongoDB version). + * Suffix change occurs under specific circumstances: + * Helm setting for appdb image: `mongodb.name` will now default to `mongodb-enterprise-server`. + * The operator will automatically replace the suffix for image repositories + that end with `mongodb-enterprise-server`. + Operator will replace the suffix `-ent` with the value set in the environment variable + `MDB_IMAGE_TYPE`, which defaults to `-ubi8`. + For instance, the operator will migrate: + * `quay.io/mongodb/mongodb-enterprise-server:4.2.11-ent` to `quay.io/mongodb/mongodb-enterprise-server:4.2.11-ubi8`. + * `MDB_IMAGE_TYPE=ubuntu2024 quay.io/mongodb/mongodb-enterprise-server:4.2.11-ent` to `quay.io/mongodb/mongodb-enterprise-server:4.2.11-ubuntu2024`. + * The operator will do the automatic migration of suffixes only for images + that reference the name `mongodb-enterprise-server`. + It won't perform migration for any other image name, e.g.: + * `mongodb-enterprise-appdb-database-ubi:4.0.0-ent` will not be altered + * To stop the automatic suffix migration behavior, + set the following environment variable to true: `MDB_APPDB_ASSUME_OLD_FORMAT=true` + or alternatively in the following helm chart setting: `mongodb.appdbAssumeOldFormat=true` +* Added support for defining bare versions in `spec.applicationDatabase.version`. Previously, it was required to specify AppDB's version with `-ent` suffix. Currently, it is possible to specify a bare version, e.g. `6.0.5` and the operator will convert it to `6.0.5-${MDB_IMAGE_TYPE}`. The default for environment variable `MDB_IMAGE_TYPE` is `-ubi8`. + +## Bug fixes +* Fixed MongoDBMultiCluster not watching Ops Manager's connection configmap and secret. +* Fixed support for rotating the clusterfile secret, which is used for internal x509 authentication in MongoDB and MongoDBMultiCluster resources. + +## Helm Chart +* All images reference ubi variants by default (added suffix -ubi) + * quay.io/mongodb/mongodb-enterprise-database-ubi + * quay.io/mongodb/mongodb-enterprise-init-database-ubi + * quay.io/mongodb/mongodb-enterprise-ops-manager-ubi + * quay.io/mongodb/mongodb-enterprise-init-ops-manager-ubi + * quay.io/mongodb/mongodb-enterprise-init-appdb-ubi + * quay.io/mongodb/mongodb-agent-ubi + * quay.io/mongodb/mongodb-enterprise-appdb-database-ubi +* Changed default AppDB repository to official MongoDB Enterprise repository in `values.mongodb.name` field: quay.io/mongodb/mongodb-enterprise-server. +* Introduced `values.mongodb.imageType` variable to specify a default image type suffix added to AppDB's version used by MongoDBOpsManager resource. + +## Breaking changes +* Removal of `appdb.connectionSpec.Project` since it has been deprecated for over 2 years. + + + +# MongoDB Enterprise Kubernetes Operator 1.19.0 + +## MongoDB Resource +* Added support for setting replica set member votes by introducing the `spec.memberOptions.[*].votes` field. +* Added support for setting replica set member priority by introducing the `spec.memberOptions.[*].priority` field. +* Added support for setting replica set member tags by introducing the `spec.memberOptions.[*].tags` field. + +## MongoDBMulti Resource +* Added support for setting replica set member votes by introducing the `spec.clusterSpecList.[*].memberOptions.[*].votes` field. +* Added support for setting replica set member priority by introducing the `spec.clusterSpecList.[*].memberOptions.[*].priority` field. +* Added support for setting replica set member tags by introducing the `spec.clusterSpecList.[*].memberOptions.[*].tags` field. + +## Improvements + +* New guidance for multi-Kubernetes-cluster deployments without a Service Mesh. It covers use of a Load Balancer Service +to expose ReplicaSet members on an externally reachable domain (`spec.externalAccess.externalDomain`). +This leverages setting the `process.hostname` field in the Automation Config. +[This tutorial](ttps://www.mongodb.com/docs/kubernetes-operator/v1.19/tutorial/proper_link) provides full guidance. +* `spec.security.authentication.ldap.transportSecurity`: "none" is now a valid configuration to use no transportSecurity. +* Allows you to configure `podSpec` per shard in a MongoDB Sharded cluster by specifying an array of `podSpecs` under `spec.shardSpecificPodSpec` for each shard. + +## Deprecations + +* Making the field orgId in the project configmap a requirement. **Note**: If explicitly an empty `orgId = ""` has been chosen then OM will try to create an ORG with the project name. +* Ubuntu-based images were deprecated in favor of UBI-based images in operator version 1.17.0. In the 1.19.0 release we are removing the support for Ubuntu-based images. The ubuntu based images won't be rebuilt daily with updates. Please upgrade to the UBI-based images by following these instructions: https://www.mongodb.com/docs/kubernetes-operator/master/tutorial/migrate-k8s-images/#migrate-k8s-images +* The `spec.exposedExternally` option becomes deprecated in favor of `spec.externalAccess`. The deprecated option will be removed in MongoDB Enterprise Operator 1.22.0. + +## Bug fixes +* Fixed handling of `WATCH_NAMESPACE='*'` environment variable for multi-cluster deployments with cluster-wide operator. In some specific circumstances, API clients for member clusters were configured incorrectly resulting in deployment errors. + * Example error in this case: + * `The secret object 'mdb-multi-rs-cert' does not contain all the valid certificates needed: secrets "mdb-multi-rs-cert-pem" already exists` + * These specific circumstances were: + * `WATCH_NAMESPACE='*'` environment variable passed to the operator deployment + * specific namespace set in kubeconfig for member clusters + * not using multi-cluster cli tool for configuring + * Possible workarounds: + * set WATCH_NAMESPACE environment variable to specific namespaces instead of '*' + * make sure that kubeconfigs for member clusters doesn't specify a namespace + +## Breaking changes +* Renaming of the multicluster CRD `MongoDBMulti` to `MongoDBMultiCluster` + +* The `spec.members` field is required to be set in case of MongoDB deployment of type `ReplicaSet`. +## Bug fixes +* Fixed a panic when `CertificatesSecretsPrefix` was set but no further `spec.security.tls` setting was set i.e. `tls.additionalCertificateDomains` or `tls.ca`. + +# MongoDB Enterprise Kubernetes Operator 1.18.0 + +## Improvements + +* Added support for the missing features for Ops Manager Backup configuration page. This includes: + * KMIP Backup Configuration support by introducing `spec.backup.encryption.kmip` in both OpsManager and MongoDB resources. + * Backup Assignment Labels settings in `spec.backup.[*].assignmentLabels` elements of the OpsManager resource. + * Backup Snapshot Schedule configuration via `spec.backup.snapshotSchedule` in the OpsManager resource. +* Added `SCRAM-SHA-1` support for both user and Agent authentication. Before enabling this capability, make sure you use both `MONGODB-CR` and `SCRAM-SHA-1` in the authentication modes. + +## Bug fixes +* Fixed liveness probe reporting positive result when the agent process was killed. This could cause database pods to run without automation agent. +* Fixed startup script in database pod, that could in some situations report errors on pod's restart. + +## Breaking changes and deprecations + +* The field `spec.security.tls.secretRef.prefix` has been removed from MongoDB and OpsManager resources. It was deprecated in the [MongoDB Enterprise +1.15.0](https://www.mongodb.com/docs/kubernetes-operator/master/release-notes/#k8s-op-full-1-15-0) and removed from the Operator runtime in +[1.17.0](https://www.mongodb.com/docs/kubernetes-operator/master/release-notes/#k8s-op-full-1-17-0). Before upgrading to this version, make +sure you migrated to the new TLS format using the following [Migration Guide](https://www.mongodb.com/docs/kubernetes-operator/v1.16/tutorial/migrate-to-new-tls-format/) before upgrading the Operator. + +# MongoDB Enterprise Kubernetes Operator 1.17.2 + +* Fixed the OpenShift installation problem mentioned in the Enterprise Operator 1.7.1 release notes. The OLM (Operator Lifecycle Manager) + upgrade graph will automatically skip the 1.7.1 release and perform an upgrade from 1.7.0 directly to this release. +* Adds startup probes for database and OpsManager resources with some defaults. This improves the reliability of upgrades by ensuring things occur in the correct order. Customers can also override probe configurations with `podTemplateSpec`. +# MongoDB Enterprise Kubernetes Operator 1.17.1 + +## Important OpenShift Warning +For OpenShift customers, we recommend that you do NOT upgrade to this release (version 1.17.1), and instead upgrade to version 1.17.2, which is due the week commencing 17th October 2022, or upgrade to later versions. + +This release has invalid `quay.io/mongodb/mongodb-agent-ubi` digests referenced in certified bundle's CSV. Installing it could result in ImagePullBackOff errors in AppDB pods (OpsManager's database). Errors will look similar to: +``` + Failed to pull image "quay.io/mongodb/mongodb-agent-ubi@sha256:a4cadf209ab87eb7d121ccd8b1503fa5d88be8866b5c3cb7897d14c36869abf6": rpc error: code = Unknown desc = reading manifest sha256:a4cadf209ab87eb7d121ccd8b1503fa5d88be8866b5c3cb7897d14c36869abf6 in quay.io/mongodb/mongodb-agent-ubi: manifest unknown: manifest unknown +``` +This affects only OpenShift deployments when installing/upgrading the Operator version 1.17.1 from the certified bundle (OperatorHub). + +The following workaround fixes the issue by replacing the invalid sha256 digests. + +### Workaround + +If you proceed to use the Operator version 1.17.1 in OpenShift, you must make the following changes. Update the Operator's Subscription with the following `spec.config.env`: +```yaml +spec: + config: + env: + - name: AGENT_IMAGE + value: >- + quay.io/mongodb/mongodb-agent-ubi@sha256:ffa842168cc0865bba022b414d49e66ae314bf2fd87288814903d5a430162620 + - name: RELATED_IMAGE_AGENT_IMAGE_11_0_5_6963_1 + value: >- + quay.io/mongodb/mongodb-agent-ubi@sha256:e7176c627ef5669be56e007a57a81ef5673e9161033a6966c6e13022d241ec9e + - name: RELATED_IMAGE_AGENT_IMAGE_11_12_0_7388_1 + value: >- + quay.io/mongodb/mongodb-agent-ubi@sha256:ffa842168cc0865bba022b414d49e66ae314bf2fd87288814903d5a430162620 + - name: RELATED_IMAGE_AGENT_IMAGE_12_0_4_7554_1 + value: >- + quay.io/mongodb/mongodb-agent-ubi@sha256:3e07e8164421a6736b86619d9d72f721d4212acb5f178ec20ffec045a7a8f855 +``` + +**This workaround should be removed as soon as the new Operator version (>=1.17.2) is installed.** + +## MongoDB Operator + +## Improvements + +* The Red Hat certified operator now uses Quay as an image registry. New images will be automatically pulled upon the operator upgrade and no user action is required as a result of this change. + +## Breaking changes + +* Removed `operator.deployment_name` from the Helm chart. Parameter was used in an incorrect way and only for customising the name of the operator container. The name of the container is now set to `operator.name`. This is a breaking change only if `operator.deployment_name` was set to a different value than `operator.name` and if there is external tooling relying on this. Otherwise this change will be unnoticeable. + +# MongoDB Enterprise Kubernetes Operator 1.17.0 + +## Improvements + +* Introduced support for Ops Manager 6.0. +* For custom S3 compatible backends for the Oplog and Snapshot stores, it is now possible to specify the + `spec.backup.s3OpLogStores[n].s3RegionOverride` and the `spec.backup.s3Stores[n].s3RegionOverride` parameter. +* Improved security by introducing `readOnlyRootFilesystem` property to all deployed containers. This change also introduces a few additional volumes and volume mounts. +* Improved security by introducing `allowPrivilegeEscalation` set to `false` for all containers. + +## Breaking changes and deprecations + +* Ubuntu-based images are being deprecated in favor of the UBI-based images for new users, a migration guide for existing users will be published soon. + The Ubuntu-based images will no longer be made available as of version 1.19. All existing Ubuntu-based images will continue to be + supported until their version [End Of Life](https://www.mongodb.com/docs/kubernetes-operator/master/reference/support-lifecycle/). + It is highly recommended to switch into UBI-based images as soon as possible. +* Concatenated PEM format TLS certificates are not supported in Operator 1.17.0 and above. They were deprecated in + 1.13.0. Before upgrading to Operator 1.17.0, please confirm you have upgraded to the `Kubernetes TLS`. Please refer to the + Migration [Migration Guide](https://www.mongodb.com/docs/kubernetes-operator/v1.16/tutorial/migrate-to-new-tls-format/) before upgrading the Operator. +* Ops Manager 4.4 is [End of Life](https://www.mongodb.com/support-policy/lifecycles) and is no longer supported by the operator. If you're + using Ops Manager 4.4, please upgrade to a newer version prior to the operator upgrade. + +# MongoDB Enterprise Kubernetes Operator 1.16.4 + +## Security fixes + +* The operator and init-ops-manager binaries are built with Go 1.18.4 which addresses security issues. + +# MongoDB Enterprise Kubernetes Operator 1.16.3 + +## MongoDB Resource + +* Security Context are now defined only at Pod level (not both Pod and Container level as before). +* Added `timeoutMS`, `userCacheInvalidationInterval` fields to `spec.security.authentication.ldap` object. + +* Bug fixes + * Fixes ignored `additionalMongodConfig.net.tls.mode` for `mongos`, `configSrv` and `shard` objects when configuring ShardedCluster resource. + +# MongoDB Enterprise Kubernetes Operator 1.16.2 + +## MongoDB Resource + +* `spec.podSpec.podAntiAffinityTopologyKey` , `spec.podSpec.podAffinity` and `spec.podSpec.nodeAffinity` has been removed. Please use `spec.podSpec.podTemplate` override to set these fields. +* Wiredtiger cache computation has been removed. This was needed for server version `>=4.0.0 <4.0.9` and `<3.6.13`. These server version have reached EOL. Make sure to update your MDB deployment to a version later than `4.0.9` before upgrading the operator. + +## MongoDBOpsManager Resource + +* `spec.applicationDatabase.podSpec.podAntiAffinityTopologyKey` , `spec.applicationDatabase.podSpec.podAffinity` and `spec.applicationDatabase.podSpec.nodeAffinity` has been removed. Please use `spec.applicationDatabase.podSpec.podTemplate` override to set these fields. +# MongoDB Enterprise Kubernetes Operator 1.16.1 + +## MongoDB Resource + +* `spec.Service` has been deprecated. Please use `spec.statefulSet.spec.serviceName` to provide a custom service name. + +# MongoDB Enterprise Kubernetes Operator 1.15.3 + +## MongoDB Resource + +* `spec.security.tls.secretRef.name` has been removed. It was deprecated in operator version `v1.10.0`. Please use the field + `spec.security.certsSecretPrefix` to specify the secret name containing the certificate for Database. Make sure to create the secret containing the certificates accordingly. +* `spec.podSpec.cpu` and `spec.podSpec.memory` has been removed to override the CPU/Memory resources for the + database pod, you need to override them using the statefulset spec override under `spec.podSpec.podTemplate.spec.containers`. +* Custom labels specified under `metadata.labels` is propagated to the database StatefulSet and the PVC objects. + +## MongoDBOpsManager Resource +* `spec.applicationDatabase.security.tls.secretRef.name` has been removed. It was deprecated in operator version `v1.10.0`. Please use the field +`spec.applicationDatabase.security.certsSecretPrefix` to specify the secret name containing the certificate for AppDB. Make sure to create the secret containing the certificates accordingly. +* * Custom labels specified under `metadata.labels` is propagated to the OM, AppDB and BackupDaemon StatefulSet and the PVC objects. + +## MongoDBUser Resource +* Changes: + * Added the optional field `spec.connectionStringSecretName` to be able to provide a deterministic secret name for the user specific connection string secret generated by the operator. + +* `spec.applicationDatabase.podSpec.cpu` and `spec.applicationDatabase.podSpec.memory` has been removed to override the CPU/Memory resources for the + appDB pod, you need to override them using the statefulset spec override under `spec.applicationDatabase.podSpec.podTemplate.spec.containers`. +# MongoDB Enterprise Kubernetes Operator 1.15.2 +## MongoDBOpsManager Resource +* Bug Fix + * For enabling custom TLS certificates for S3 Oplog and Snapshot stores for backup. In additioning to setting `spec.security.tls.ca` and `spec.security.tls.secretRef`. The field `spec.backup.s3OpLogStores[n].customCertificate` / `spec.backup.s3Stores[n].customCertificate` needs to be set `true`. + +# MongoDB Enterprise Kubernetes Operator 1.15.1 + +## MongoDBOpsManager Resource + +* Bug fixes + * Fixes an issue that prevented the Operator to be upgraded when managing a TLS + enabled ApplicationDB, when the ApplicationDB TLS certificate is stored in a + `Secret` of type Opaque. + + +# MongoDB Enterprise Kubernetes Operator 1.15.0 + + +## MongoDB Resource + +* Changes: + * The `spec.security.tls.enabled` and `spec.security.tls.secretRef.prefix` fields are now **deprecated** and will be removed in a future release. To enable TLS it is now sufficient to set the `spec.security.certsSecretPrefix` field. + +## MongoDBOpsManager Resource + +* Changes: + * A new field has been added: `spec.backup.queryableBackupSecretRef`. The secrets referenced by this field contains the certificates used to enable [Queryable Backups](https://docs.opsmanager.mongodb.com/current/tutorial/query-backup/) feature. + * Added support for configuring custom TLS certificates for the S3 Oplog and Snapshot Stores for backup. These can be configured with + `spec.security.tls.ca` and `spec.security.tls.secretRef`. + * It is possible to disable AppDB processes via the `spec.applicationDatabase.automationConfig.processes[n].disabled` field, this enables backing up the AppDB. + * The `spec.security.tls.enabled`, `spec.security.tls.secretRef.prefix`, `spec.applicationDatabase.security.tls.enabled` and `spec.applicationDatabase.security.tls.prefix` fields are now **deprecated** and will be removed in a future release. To enable TLS it is now sufficient to set the `spec.security.certsSecretPrefix` and/or `spec.applicationDatabase.security.certsSecretPrefix` field. + +*All the images can be found in:* + +https://quay.io/repository/mongodb (ubuntu-based) + +https://connect.redhat.com/ (rhel-based) + + +# MongoDB Enterprise Kubernetes Operator 1.14.0 + + +## MongoDB Resource +* Changes: + * A new field has been added: `spec.backup.autoTerminateOnDeletion`. AutoTerminateOnDeletion indicates if the Operator should stop and terminate the Backup before the cleanup, when the MongoDB Resource is deleted. +* Bug fixes + * Fixes an issue which would make a ShardedCluster Resource fail when disabling authentication. + +## MongoDBOpsManager Resource + +* Bug Fixes + * Fixes an issue where the operator would not properly trigger a reconciliation when rotating the AppDB TLS Certificate. + * Fixes an issue where a custom CA specified in the MongoDBOpsManager resource was not mounted into the Backup Daemon pod, + which prevented backups from working when Ops Manager was configured in hybrid mode and used a custom CA. +* Changes + * Added support for configuring S3 Oplog Stores using the `spec.backup.s3OpLogStores` field. + + +# MongoDB Enterprise Kubernetes Operator 1.13.0 + +## Kubernetes Operator +* Breaking Changes: + * The Operator no longer generates certificates for TLS resources. +* When deploying to multiple namespaces, imagePullSecrets has to be created only in the namespace where the Operator is installed. From here, the Operator will be sync this secret across all watched namespaces. +* The credentials secret used by the Operator now accepts the pair of fields `publicKey` and `privateKey`. These should be preferred to the existent `user` and `publicApiKey` when using Programmatic API Keys in Ops Manager. +* For TLS-enabled resources, the operator now watches the ConfigMap containing the Certificate Authority and the secret containg the TLS certificate. Changes to these resources now trigger a reconciliation of the related resource. +* The Operator can now watch over a list of Namespaces. To install the Operator in this mode, you need to set the value `operator.watchNamespace` to a comma-separated list of Namespaces. + The Helm install process will create Roles and Service Accounts required, in the Namespaces that the Operator will be watching. + +### Support for TLS certificates provided as kubernetes.io/tls secrets +* The operator now supports referencing TLS secrets of type kubernetes.io/tls + * This type of secrets contain a tls.crt and tls.key entry + * The operator can read these secrets and automatically generate a new one, containing the concatenation of tls.crt and tls.key + * This removes the need for a manual concatenation of the fields and enables users to natively reference secrets generated by tools such as cert-manager + +**Deprecation Notice** +The usage of generic secrets, manually created by concatenating certificate and private key, is now deprecated. + +## MongoDB Resource +* Breaking Changes: + * The field `spec.project` has been removed from MongoDB spec, this field has been deprecated since operator version `1.3.0`. Make sure to specify the project configmap name under `spec.opsManager.configMapRef.name` or ``spec.cloudManager.configMapRef.name`` before upgrading the operator. +* Changes: + * A new field has been added: `spec.security.certsSecretPrefix`. This string is now used to determine the name of the secrets containing various TLS certificates: + * For TLS member certificates, the secret name is `--cert` + * Note: If either `spec.security.tls.secretRef.name` or `spec.security.tls.secretRef.prefix` are specified, these will take precedence over the new field + * Note: if none of these three fields are specified, the secret name is `-cert` + * For agent certificates, if `spec.security.certsSecretPrefix` is specified, the secret name is`--agent-certs` + * Note: if `spec.authentication.agents.clientCertificateSecretRef` is specified, this will take precedence over the new field + * If none of these fields are set, the secret name is still `agent-certs` + * For internal cluster authentication certificates, if `spec.security.certsSecretPrefix` is specified, the secret name is `--clusterfile` + * Otherwise, it is still `-clusterfile` +* Bug fixes + * Fixes an issue where Sharded Cluster backups could not be correctly configured using the MongoDB CR. + * Fixes an issue where Backup Daemon fails to start after OpsManager version upgrade. + + +## MongoDBOpsManager Resource +* Operator will report status of FileSystemSnaphot store names configured under `spec.backup.fileSystemStores` in OM CR. The FS however needs to be manually configured. +* It is now possible to disable creation of "LoadBalancer" Type service for queryable backup by setting `spec.backup.externalServiceEnabled` to `false` in OM CR. By default, the operator would create the LoadBalancer type service object. +* The operator will now automatically upgrade the used API Key to a programmatic one when deploying OM >= 5.0.0. It is now possible to upgrade from older versions of OM to OM 5.0 without manual intervention. +* A new field has been added: `spec.security.certSecretPrefix`. This is string is now used to determine the name of the secret containing the TLS certificate for OpsManager. + * If the existing field `spec.security.tls.secretRef.Name` is specified, it will take the precedence + * Please note that this field is now **deprecated** and will be removed in a future release + * Otherwise, if `spec.security.certSecretPrefix` is specified, the secret name will be `--cert` + +## MongoDBUser Resource +* Breaking Changes: + * The field `spec.project` has been removed from User spec, this field has been deprecated since operator version `1.3.0`. Make sure to specify the MongoDB resource name under `spec.MongoDBResourceRef.name` before upgrading the operator. +## Miscellaneous +* Ops Manager versions 4.4.7, 4.4.9, 4.4.10, 4.4.11, 4.4.12 and 4.4.13 base images have been updated to Ubuntu 20.04. +* Ops Manager versions 4.4.16 and 5.0.1 are now supported + + +# MongoDB Enterprise Kubernetes Operator 1.12.0 + +## MongoDB Resource +* Bug Fixes + * Fixes a bug when an user could only specify `net.ssl.mode` and not `net.tls.mode` in the `spec.additionalMongodConfig` field. +* Changes + * If `spec.exposedExternally` is set to `false` after being set to `true`, the Operator will now delete the corresponding service + +## MongoDBOpsManager Resource +* Changes + * If `spec.externalConnectivity` is unset after being set, the Operator will now delete the corresponding service + * It is now possible to specify the number of backup daemon pods to deploy through the `spec.backup.members` field. The value defaults to 1 if not set. + + +## Miscellaneous +* Ops Manager versions 4.4.13, 4.4.14, 4.4.15 and 4.2.25 are now supported +* Ops Manager version 5.0.0 is now supported +* Ubuntu based operator images are now based on Ubuntu 20.04 instead of Ubuntu 16.04 +* Ubuntu based database images starting from 2.0.1 will be based on Ubuntu 18.04 instead of Ubuntu 16.04 + **NOTE: MongoDB 4.0.0 does not support Ubuntu 18.04 - If you want to use MongoDB 4.0.0, stay on previously released images** +* Ubuntu based Ops Manager images after 4.4.13 will be based on Ubuntu 20.04 instead of Ubuntu 16.04 + +* Newly released ubi images for Operator, Ops Manager and Database will be based un ubi-minimal instead of ubi +## Notes before upgrading to OpsManager 5.0.0 +* Before upgrading OpsManager to version 5.0.0 make sure the Operator is using a [programmatic API key](https://docs.mongodb.com/kubernetes-operator/stable/tutorial/create-operator-credentials/#create-k8s-credentials). This is only required when the OpsManager instance is managed by the Operator. +* You will find a small tutorial on how to do this in [upgrading-to-ops-manager-5.md](docs/upgrading-to-ops-manager-5.md) document. + +# MongoDB Enterprise Kubernetes Operator 1.11.0 + +## MongoDB Resource +* Bug fixes + * Fixes an issue with the `LivenessProbe` that could cause the database Pods to be restarted in the middle of a restore operation from Backup. + +## MongoDBOpsManager Resource +* Breaking Changes + *For a complete guide on how to safely upgrade, please check the [upgrade instructions](link here TODO)* + * Each Application Database pod consists now of three containers (`mongodb`, `mongodb-agent`, `mongodb-agent-monitoring`) and it does not bundle anymore a MongoDB version + * You can now use any version of MongoDB for the Application Database (we recommend to use the enterprise ones provided by MongoDB, see the *New Images* section) + * You need to make sure the MongoDB version used is [supported](https://docs.opsmanager.mongodb.com/current/reference/mongodb-compatibility/) by OpsManager + * `spec.applicationDatabase.version` is no longer optional. + * `spec.applicationDatabase.persistent` does not exist anymore, the Operator will now always use persistent volumes for the AppDB. + +## New Images + +* mongodb-agent 10.29.0.6830-1: + * Ubi: quay.io/mongodb/mongodb-agent-ubi:10.29.0.6830-1 + * Ubuntu: quay.io/mongodb/mongodb-agent:10.29.0.6830-1 + +* mongodb-enterprise-appdb-database + * Ubi: quay.io/mongodb/mongodb-enterprise-appdb-database-ubi + * Ubuntu: quay.io/mongodb/mongodb-enterprise-appdb-database + +* mongodb-enterprise-init-appdb 1.0.7 + * Ubi: quay.io/mongodb/mongodb-enterprise-init-appdb-ubi:1.0.7 + * Ubuntu: quay.io/mongodb/mongodb-enterprise-init-appdb:1.0.7 + +* mongodb-enterprise-init-database 1.0.3 + * Ubi: quay.io/mongodb/mongodb-enterprise-init-database-ubi:1.0.3 + * Ubuntu: quay.io/mongodb/mongodb-enterprise-init-database:1.0.3 + +# MongoDB Enterprise Kubernetes Operator 1.10.1 + +## Kubernetes Operator +* Changes + * Added a liveness probe to the Backup Daemon. + * Added a readiness probe to the Backup Daemon. + * The readiness probe on Database Pods is more strict when restarting a + Replica Set and will only set the Pod as "Ready" when the MongoDB server has + reached `PRIMARY` or `SECONDARY` states. + +## MongoDB Resource +* Changes + * Deprecated field `spec.security.tls.secretRef.name`, the field `spec.security.tls.secretRef.prefix` should now be used instead. + * Added field `spec.security.tls.secretRef.prefix`. This property should be used to specify the prefix of the secret which contains custom tls certificates. + +## MongoDBOpsManager Resource +* Changes + * A new status field for the OpsManager backup has been added: `Disabled`. This status will be displayed when `spec.backup.enabled` is set to `false` and no backup is configured in OpsManager + +## Miscellaneous +* Added a new value in openshift-values.yaml `operator_image_name` which allows the label selector of the webhook + to match the operator label. + + +## MongoDB Resource +* Changes + * Deprecated field `spec.security.tls.secretRef.name`, the field `spec.security.tls.secretRef.prefix` should now be used instead. + * Added field `spec.security.tls.secretRef.prefix`. This property should be used to specify the prefix of the secret which contains custom tls certificates. + + +# MongoDB Enterprise Kubernetes Operator 1.10.0 + +## Kubernetes Operator + +* Changes + * The CRDs have been updated to from `v1beta1` to `v1` version. This should not have any impact on Kubernetes clusters 1.16 and up. The CRDs won't be installable in clusters with versions older than 1.16. + +* Bug fixes + * Fixes an issue which made it not possible do have multiple ops-manager resources with the same name in different namespaces. + * Fixes an issue which made new MongoDB resources created with `spec.backup.mode=disabled` fail. + * Fixes an issue which made a Replica Set go to Fail state if, at the same time, the amount of members of a Replica Set are increased and TLS is disabled. + +## MongoDBOpsManager Resource + +* Known issues + * When using remote or hybrid mode, and `automation.versions.download.baseUrl` has been set, the property `automation.versions.download.baseUrl.allowOnlyAvailableBuilds` + needs to be set to `false`. This has been fixed in Ops Manager version 4.4.11. + + +# MongoDB Enterprise Kubernetes Operator 1.9.3 +## Kubernetes Operator + +* Changes + * The CRDs have been updated to from `v1beta1` to `v1` version. This should not have any impact on Kubernetes clusters 1.16 and up. The CRDs won't be installable in clusters with versions older than 1.16. + +* Bug fixes + * Fixes an issue which made it not possible do have multiple ops-manager resources with the same name in different namespaces. + * Fixes an issue which made new MongoDB resources created with `spec.backup.mode=disabled` fail. + * Fixes an issue which made a Replica Set go to Fail state if, at the same time, the amount of members of a Replica Set are increased and TLS is disabled. + +## MongoDBOpsManager Resource + +* Known issues + * When using remote or hybrid mode, and `automation.versions.download.baseUrl` has been set, the property `automation.versions.download.baseUrl.allowOnlyAvailableBuilds` + needs to be set to `false`. This has been fixed in Ops Manager version 4.4.11. + + +# MongoDB Enterprise Kubernetes Operator 1.9.3 +## Kubernetes Operator +* Bug fixes + * Fixes an issue which made it not possible do have multiple ops-manager resources with the same name in different namespaces + * Fixes an issue which made new MongoDB resources created with `spec.backup.mode=disabled` fail + * Fixes an issue which made a Replica Set go to Fail state if, at the same time, the amount of members of a Replica Set are increased and TLS is disabled. + +## MongoDBOpsManager Resource +* Known issues + * When using remote or hybrid mode, and `automation.versions.download.baseUrl` has been set, the property `automation.versions.download.baseUrl.allowOnlyAvailableBuilds` + needs to be set to `false`. This has been fixed in Ops Manager version 4.4.11. + + +# MongoDB Enterprise Kubernetes Operator 1.9.2 +## Miscellaneous +* Fix errors with CSV + + + +# MongoDB Enterprise Kubernetes Operator 1.9.1 +## Kubernetes Operator +* Bug fixes + * Fixes an issue where the service-account-name could not be specified in the StatefulSet podSpec override. + * Removed unnecessary `delete service` permission from operator role. + +## MongoDB Resource +* Bug fixes + * Fixes an issue where updating a role in `spec.security.authentication.roles` by removing the `privileges` array would cause the resource to enter a bad state + +## MongoDBOpsManager Resource +* Breaking Changes + * The new Application Database image `mongodb-enterprise-appdb:10.2.15.5958-1_4.2.11-ent` was released. The image needs + to be downloaded to the local repositories otherwise MongoDBOpsManager resource won't start. The image contains a new bundled MongoDB + `4.2.11-ent` instead of `4.2.2-ent`. +* Changes + * Ops Manager user now has "backup", "restore" and "hostManager" roles, allowing for backups/restores on the AppDB. + * If `spec.applicationDatabase.version` is omitted the Operator will use `4.2.11-ent` as a default MongoDB. + +# MongoDB Enterprise Kubernetes Operator 1.9.0 + +## Kubernetes Operator + +* Bug fixes + * Fixes an issue where connections were not closed leading to too many file + descriptors open. + +## MongoDB Resource +* Changes + * Continuous backups can now be configured with the MongoDB CRD. Set `spec.backup.enabled` to `true`. *Note*: You must have an Ops Manager resource already configured with backup. See [the docs](https://docs.mongodb.com/kubernetes-operator/master/tutorial/deploy-om-container/#id6) for more information. +## MongoDBOpsManager Resource + +* Changes + * A StatefulSet resource that holds the Ops Manager Backup Daemon will be + deleted and recreated in order to change the `matchLabels` attribute, + required for a new `Service` to allow for Queryable Backups feature to work. + This is a safe operation. + * Changed the way the Operator collects statuses of MongoDB Agents running in + Application Database Pods. + +# MongoDB Enterprise Kubernetes Operator 1.8.2 + +## MongoDBOpsManager Resource + +* Bug Fixes + * Fixes an issue when `MongoDBOpsManager` resource gets to `Failing` state when + both external connectivity and backups are enabled. + +## New Images + +* mongodb-enterprise-operator 1.8.2: + * Ubi: quay.io/mongodb/mongodb-enterprise-operator-ubi:1.8.2 + * Ubuntu: quay.io/mongodb/mongodb-enterprise-operator:1.8.2 diff --git a/api/v1/addtoscheme_mongodb_v1.go b/api/v1/addtoscheme_mongodb_v1.go new file mode 100644 index 000000000..81b01b1c5 --- /dev/null +++ b/api/v1/addtoscheme_mongodb_v1.go @@ -0,0 +1,6 @@ +package v1 + +func init() { + // Register the types with the Scheme so the components can map objects to GroupVersionKinds and back + AddToSchemes = append(AddToSchemes, SchemeBuilder.AddToScheme) +} diff --git a/api/v1/apis.go b/api/v1/apis.go new file mode 100644 index 000000000..01683259a --- /dev/null +++ b/api/v1/apis.go @@ -0,0 +1,13 @@ +package v1 + +import ( + "k8s.io/apimachinery/pkg/runtime" +) + +// AddToSchemes may be used to add all resources defined in the project to a Scheme +var AddToSchemes runtime.SchemeBuilder + +// AddToScheme adds all Resources to the Scheme +func AddToScheme(s *runtime.Scheme) error { + return AddToSchemes.AddToScheme(s) +} diff --git a/api/v1/customresource_readwriter.go b/api/v1/customresource_readwriter.go new file mode 100644 index 000000000..92988416a --- /dev/null +++ b/api/v1/customresource_readwriter.go @@ -0,0 +1,15 @@ +package v1 + +import ( + "github.com/10gen/ops-manager-kubernetes/api/v1/status" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// CustomResourceReadWriter is an interface for all Custom Resources with Status read/write capabilities +// TODO this may be a good candidate for further refactoring +// +kubebuilder:object:generate=false +type CustomResourceReadWriter interface { + client.Object + status.Reader + status.Writer +} diff --git a/api/v1/doc.go b/api/v1/doc.go new file mode 100644 index 000000000..920e76ec9 --- /dev/null +++ b/api/v1/doc.go @@ -0,0 +1,5 @@ +// +k8s:deepcopy-gen=package,register + +// Package v1 is the v1 version of the API. +// +groupName=mongodb.com +package v1 diff --git a/api/v1/kmip.go b/api/v1/kmip.go new file mode 100644 index 000000000..ed19d8580 --- /dev/null +++ b/api/v1/kmip.go @@ -0,0 +1,55 @@ +package v1 + +import ( + "fmt" +) + +// KmipServerConfig contains the relevant configuration for KMIP integration. +type KmipServerConfig struct { + // KMIP Server url in the following format: hostname:port + // Valid examples are: + // 10.10.10.3:5696 + // my-kmip-server.mycorp.com:5696 + // kmip-svc.svc.cluster.local:5696 + // +kubebuilder:validation:Pattern=`[^\:]+:[0-9]{0,5}` + URL string `json:"url"` + + // CA corresponds to a ConfigMap containing an entry for the CA certificate (ca.pem) + // used for KMIP authentication + CA string `json:"ca"` +} + +// KmipClientConfig contains the relevant configuration for KMIP integration. +type KmipClientConfig struct { + // A prefix used to construct KMIP client certificate (and corresponding password) Secret names. + // The names are generated using the following pattern: + // KMIP Client Certificate (TLS Secret): + // --kmip-client + // KMIP Client Certificate Password: + // --kmip-client-password + // The expected key inside is called "password". + // +optional + ClientCertificatePrefix string `json:"clientCertificatePrefix"` +} + +func (k *KmipClientConfig) ClientCertificateSecretName(crName string) string { + if len(k.ClientCertificatePrefix) == 0 { + return fmt.Sprintf("%s-kmip-client", crName) + } + return fmt.Sprintf("%s-%s-kmip-client", k.ClientCertificatePrefix, crName) +} + +func (k *KmipClientConfig) ClientCertificatePasswordSecretName(crName string) string { + if len(k.ClientCertificatePrefix) == 0 { + return fmt.Sprintf("%s-kmip-client-password", crName) + } + return fmt.Sprintf("%s-%s-kmip-client-password", k.ClientCertificatePrefix, crName) +} + +func (k *KmipClientConfig) ClientCertificateSecretKeyName() string { + return "tls.crt" +} + +func (k *KmipClientConfig) ClientCertificatePasswordKeyName() string { + return "password" +} diff --git a/api/v1/mdb/doc.go b/api/v1/mdb/doc.go new file mode 100644 index 000000000..49d34fef6 --- /dev/null +++ b/api/v1/mdb/doc.go @@ -0,0 +1,4 @@ +package mdb + +// +k8s:deepcopy-gen=package +// +versionName=v1 diff --git a/api/v1/mdb/groupversion_info.go b/api/v1/mdb/groupversion_info.go new file mode 100644 index 000000000..ef921b6eb --- /dev/null +++ b/api/v1/mdb/groupversion_info.go @@ -0,0 +1,20 @@ +// Package v1 contains API Schema definitions for the mongodb v1 API group +// +kubebuilder:object:generate=true +// +groupName=mongodb.com +package mdb + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects + GroupVersion = schema.GroupVersion{Group: "mongodb.com", Version: "v1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/api/v1/mdb/mongodb_roles_validation.go b/api/v1/mdb/mongodb_roles_validation.go new file mode 100644 index 000000000..1a851a08e --- /dev/null +++ b/api/v1/mdb/mongodb_roles_validation.go @@ -0,0 +1,301 @@ +package mdb + +// IMPORTANT: this package is intended to contain only "simple" validation—in +// other words, validation that is based only on the properties in the MongoDB +// resource. More complex validation, such as validation that needs to observe +// the state of the cluster, belongs somewhere else. + +import ( + "net" + + v1 "github.com/10gen/ops-manager-kubernetes/api/v1" + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" + "github.com/blang/semver" + "golang.org/x/xerrors" +) + +// Go doesn't allow us to define constant array, so we wrap it in a function + +// This is the list of valid actions for pivileges defined on the DB level +func validDbActions() []string { + return []string{"changeCustomData", + "changeOwnCustomData", + "changeOwnPassword", + "changePassword", + "createCollection", + "createIndex", + "createRole", + "createUser", + "dropCollection", + "dropRole", + "dropUser", + "emptycapped", + "enableProfiler", + "grantRole", + "killCursors", + "listCachedAndActiveUsers", + "revokeRole", + "setAuthenticationRestriction", + "unlock", + "viewRole", + "viewUser", + "find", + "insert", + "remove", + "update", + "bypassDocumentValidation", + "changeStream", + "planCacheRead", + "planCacheWrite", + "planCacheIndexFilter", + "storageDetails", + "enableSharding", + "getShardVersion", + "moveChunk", + "splitChunk", + "splitVector", + "collMod", + "compact", + "convertToCapped", + "dropDatabase", + "dropIndex", + "reIndex", + "renameCollectionSameDB", + "repairDatabase", + "collStats", + "dbHash", + "dbStats", + "indexStats", + "listCollections", + "listIndexes", + "validate"} +} + +// This is the list of valid actions for pivileges defined on the Cluster level +func validClusterActions() []string { + return []string{"useUUID", + "dropConnections", + "killAnyCursor", + "unlock", + "authSchemaUpgrade", + "cleanupOrphaned", + "cpuProfiler", + "inprog", + "invalidateUserCache", + "killop", + "appendOplogNote", + "replSetConfigure", + "replSetGetConfig", + "replSetGetStatus", + "replSetHeartbeat", + "replSetStateChange", + "resync", + "addShard", + "flushRouterConfig", + "getShardMap", + "listShards", + "removeShard", + "shardingState", + "applicationMessage", + "closeAllDatabases", + "connPoolSync", + "forceUUID", + "fsync", + "getParameter", + "hostInfo", + "logRotate", + "setParameter", + "shutdown", + "touch", + "impersonate", + "listSessions", + "killAnySession", + "connPoolStats", + "cursorInfo", + "diagLogging", + "getCmdLineOpts", + "getLog", + "listDatabases", + "netstat", + "serverStatus", + "top", + } +} + +func validateAuthenticationRestriction(ar AuthenticationRestriction) v1.ValidationResult { + clientSources := ar.ClientSource + serverAddresses := ar.ServerAddress + + // Validate all clientSources, they have to be either valid IP addresses or CIDR ranges + for _, clientSource := range clientSources { + if !(isValidIp(clientSource) || isValidCIDR(clientSource)) { + return v1.ValidationError("clientSource %s is neither a valid IP address nor a valid CIDR range", clientSource) + } + } + + // validate all serveraddresses, they have to be either valid IP addresses or CIDR ranges + for _, serverAddress := range serverAddresses { + if !(isValidIp(serverAddress) || isValidCIDR(serverAddress)) { + return v1.ValidationError("serverAddress %s is neither a valid IP address nor a valid CIDR range", serverAddress) + } + } + + return v1.ValidationSuccess() +} + +// isVersionAtLeast takes two strings representing version (in semver notation) +// and returns true if the first one is greater or equal the second +// false otherwise +func isVersionAtLeast(mdbVersion string, expectedVersion string) (bool, error) { + currentV, err := semver.Make(mdbVersion) + if err != nil { + return false, xerrors.Errorf("error parsing mdbVersion %s with semver: %w", mdbVersion, err) + } + expectedVersionSemver, err := semver.Make(expectedVersion) + if err != nil { + return false, xerrors.Errorf("error parsing mdbVersion %s with semver: %w", expectedVersion, err) + } + return currentV.GTE(expectedVersionSemver), nil +} + +func validateClusterPrivilegeActions(actions []string, mdbVersion string) v1.ValidationResult { + + isAtLeastThreePointSix, err := isVersionAtLeast(mdbVersion, "3.6.0-0") + if err != nil { + return v1.ValidationError("Error when parsing version strings: %s", err) + } + isAtLeastFourPointTwo, err := isVersionAtLeast(mdbVersion, "4.2.0-0") + if err != nil { + return v1.ValidationError("Error when parsing version strings: %s", err) + } + invalidActionsForLessThanThreeSix := []string{"impersonate", "listSessions", "killAnySession", "useUUID", "forceUUID"} + invalidActionsForLessThanFourTwo := []string{"dropConnections", "killAnyCursor"} + + if !isAtLeastFourPointTwo { + // Return error if the privilege specifies actions that are not valid in MongoDB < 4.2 + if stringutil.ContainsAny(actions, invalidActionsForLessThanFourTwo...) { + return v1.ValidationError("Some of the provided actions are not valid for MongoDB %s", mdbVersion) + } + if !isAtLeastThreePointSix { + // Return error if the privilege specifies actions that are not valid in MongoDB < 3.6 + if stringutil.ContainsAny(actions, invalidActionsForLessThanThreeSix...) { + return v1.ValidationError("Some of the provided actions are not valid for MongoDB %s", mdbVersion) + } + } + } + + // Check that every action provided is valid + for _, action := range actions { + if !stringutil.Contains(validClusterActions(), action) { + return v1.ValidationError("%s is not a valid cluster action", action) + } + } + return v1.ValidationSuccess() +} + +func validateDbPrivilegeActions(actions []string, mdbVersion string) v1.ValidationResult { + isAtLeastThreePointSix, err := isVersionAtLeast(mdbVersion, "3.6.0-0") + if err != nil { + return v1.ValidationError("Error when parsing version strings: %s", err) + } + isAtLeastFourPointTwo, err := isVersionAtLeast(mdbVersion, "4.2.0-0") + if err != nil { + return v1.ValidationError("Error when parsing version strings: %s", err) + } + invalidActionsForLessThanThreeSix := []string{"setAuthenticationRestriction", "changeStream"} + + if !isAtLeastFourPointTwo { + // Return error if the privilege specifies actions that are not valid in MongoDB < 4.2 + if stringutil.Contains(actions, "listCachedAndActiveUsers") { + return v1.ValidationError("listCachedAndActiveUsers is not a valid action for MongoDB %s", mdbVersion) + } + if !isAtLeastThreePointSix { + // Return error if the privilege specifies actions that are not valid in MongoDB < 3.6 + if stringutil.ContainsAny(actions, invalidActionsForLessThanThreeSix...) { + return v1.ValidationError("Some of the provided actions are not valid for MongoDB %s", mdbVersion) + } + } + } + + // Check that every action provided is valid + for _, action := range actions { + if !stringutil.Contains(validDbActions(), action) { + return v1.ValidationError("%s is not a valid db action", action) + } + } + return v1.ValidationSuccess() +} + +func validatePrivilege(privilege Privilege, mdbVersion string) v1.ValidationResult { + if privilege.Resource.Cluster != nil { + if !*privilege.Resource.Cluster { + return v1.ValidationError("The only valid value for privilege.cluster, if set, is true") + } + if privilege.Resource.Collection != "" || privilege.Resource.Db != "" { + return v1.ValidationError("Cluster: true is not compatible with setting db/collection") + } + if res := validateClusterPrivilegeActions(privilege.Actions, mdbVersion); res.Level == v1.ErrorLevel { + return v1.ValidationError("Actions are not valid - %s", res.Msg) + } + } else { + if res := validateDbPrivilegeActions(privilege.Actions, mdbVersion); res.Level == v1.ErrorLevel { + return v1.ValidationError("Actions are not valid - %s", res.Msg) + } + } + + return v1.ValidationSuccess() +} + +func isValidIp(ip string) bool { + return net.ParseIP(ip) != nil +} + +func isValidCIDR(cidr string) bool { + _, _, err := net.ParseCIDR(cidr) + return err == nil +} + +func roleIsCorrectlyConfigured(role MongoDbRole, mdbVersion string) v1.ValidationResult { + // Extensive validation of the roles attribute + + if role.Role == "" { + return v1.ValidationError("Cannot create a role with an empty name") + } + if role.Db == "" { + return v1.ValidationError("Cannot create a role with an empty db") + } + + for _, inheritedRole := range role.Roles { + if inheritedRole.Role == "" { + return v1.ValidationError("Cannot inherit from a role with an empty name") + } + if inheritedRole.Db == "" { + return v1.ValidationError("Cannot inherit from a role with an empty db") + } + } + // authenticationRestrictions: + for _, ar := range role.AuthenticationRestrictions { + if res := validateAuthenticationRestriction(ar); res.Level == v1.ErrorLevel { + return v1.ValidationError("AuthenticationRestriction is invalid - %s", res.Msg) + } + } + + // privileges + for _, p := range role.Privileges { + if res := validatePrivilege(p, mdbVersion); res.Level == v1.ErrorLevel { + return v1.ValidationError("Privilege is invalid - %s", res.Msg) + } + } + + return v1.ValidationSuccess() +} + +func rolesAttributeisCorrectlyConfigured(d DbCommonSpec) v1.ValidationResult { + // Validate every single entry and return error on the first one that fails validation + for _, role := range d.Security.Roles { + if res := roleIsCorrectlyConfigured(role, d.Version); res.Level == v1.ErrorLevel { + return v1.ValidationError("Error validating role - %s", res.Msg) + } + } + return v1.ValidationSuccess() +} diff --git a/api/v1/mdb/mongodb_types.go b/api/v1/mdb/mongodb_types.go new file mode 100644 index 000000000..cc881d3ed --- /dev/null +++ b/api/v1/mdb/mongodb_types.go @@ -0,0 +1,1402 @@ +package mdb + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/10gen/ops-manager-kubernetes/pkg/dns" + + mdbcv1 "github.com/mongodb/mongodb-kubernetes-operator/api/v1" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/annotations" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/connectionstring" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/ldap" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/automationconfig" + + v1 "github.com/10gen/ops-manager-kubernetes/api/v1" + "github.com/10gen/ops-manager-kubernetes/api/v1/status" + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" + + "github.com/blang/semver" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" +) + +func init() { + v1.SchemeBuilder.Register(&MongoDB{}, &MongoDBList{}) +} + +type LogLevel string + +type ResourceType string + +type TransportSecurity string + +const ( + Debug LogLevel = "DEBUG" + Info LogLevel = "INFO" + Warn LogLevel = "WARN" + Error LogLevel = "ERROR" + Fatal LogLevel = "FATAL" + + Standalone ResourceType = "Standalone" + ReplicaSet ResourceType = "ReplicaSet" + ShardedCluster ResourceType = "ShardedCluster" + + TransportSecurityNone TransportSecurity = "none" + TransportSecurityTLS TransportSecurity = "tls" +) + +// MongoDB resources allow you to deploy Standalones, ReplicaSets or SharedClusters +// to your Kubernetes cluster + +// +kubebuilder:object:root=true +// +k8s:openapi-gen=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:path=mongodb,scope=Namespaced,shortName=mdb +// +kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase",description="Current state of the MongoDB deployment." +// +kubebuilder:printcolumn:name="Version",type="string",JSONPath=".status.version",description="Version of MongoDB server." +// +kubebuilder:printcolumn:name="Type",type="string",JSONPath=".spec.type",description="The type of MongoDB deployment. One of 'ReplicaSet', 'ShardedCluster' and 'Standalone'." +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="The time since the MongoDB resource was created." +type MongoDB struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + // +optional + Status MongoDbStatus `json:"status"` + Spec MongoDbSpec `json:"spec"` +} + +func (m *MongoDB) ForcedIndividualScaling() bool { + return false +} + +func (m *MongoDB) GetSpec() DbSpec { + return &m.Spec +} + +func (m *MongoDB) GetProjectConfigMapNamespace() string { + return m.GetNamespace() +} + +func (m *MongoDB) GetCredentialsSecretNamespace() string { + return m.GetNamespace() +} + +func (m *MongoDB) GetProjectConfigMapName() string { + return m.Spec.GetProject() +} + +func (m *MongoDB) GetCredentialsSecretName() string { + return m.Spec.Credentials +} + +func (m *MongoDB) GetSecurity() *Security { + return m.Spec.GetSecurity() +} + +func (m *MongoDB) GetConnectionSpec() *ConnectionSpec { + return &m.Spec.ConnectionSpec +} + +func (m *MongoDB) GetPrometheus() *mdbcv1.Prometheus { + return m.Spec.Prometheus +} + +func (m *MongoDB) GetMinimumMajorVersion() uint64 { + return m.Spec.MinimumMajorVersion() +} + +func (m *MongoDB) AddValidationToManager(mgr manager.Manager) error { + return ctrl.NewWebhookManagedBy(mgr).For(m).Complete() +} + +func (m *MongoDB) GetBackupSpec() *Backup { + return m.Spec.Backup +} + +func (m *MongoDB) GetResourceType() ResourceType { + return m.Spec.ResourceType +} + +func (m *MongoDB) IsShardedCluster() bool { + return m.GetResourceType() == ShardedCluster +} + +func (m *MongoDB) GetResourceName() string { + return m.Name +} + +// GetSecretsMountedIntoDBPod returns a list of all the optional secret names that are used by this resource. +func (m *MongoDB) GetSecretsMountedIntoDBPod() []string { + secrets := []string{} + var tls string + if m.Spec.ResourceType == ShardedCluster { + for i := 0; i < m.Spec.ShardCount; i++ { + tls = m.GetSecurity().MemberCertificateSecretName(m.ShardRsName(i)) + if tls != "" { + secrets = append(secrets, tls) + } + } + tls = m.GetSecurity().MemberCertificateSecretName(m.ConfigRsName()) + if tls != "" { + secrets = append(secrets, tls) + } + tls = m.GetSecurity().MemberCertificateSecretName(m.ConfigRsName()) + if tls != "" { + secrets = append(secrets, tls) + } + } else { + tls = m.GetSecurity().MemberCertificateSecretName(m.Name) + if tls != "" { + secrets = append(secrets, tls) + } + } + agentCerts := m.GetSecurity().AgentClientCertificateSecretName(m.Name).Name + if agentCerts != "" { + secrets = append(secrets, agentCerts) + } + if m.Spec.Security.Authentication != nil && m.Spec.Security.Authentication.Ldap != nil { + secrets = append(secrets, m.Spec.GetSecurity().Authentication.Ldap.BindQuerySecretRef.Name) + if m.Spec.Security.Authentication != nil && m.Spec.Security.Authentication.Agents.AutomationPasswordSecretRef.Name != "" { + secrets = append(secrets, m.Spec.Security.Authentication.Agents.AutomationPasswordSecretRef.Name) + } + } + return secrets +} + +func (m *MongoDB) GetHostNameOverrideConfigmapName() string { + return fmt.Sprintf("%s-hostname-override", m.Name) +} + +type AdditionalMongodConfigType int + +const ( + StandaloneConfig = iota + ReplicaSetConfig + MongosConfig + ConfigServerConfig + ShardConfig +) + +// GetLastAdditionalMongodConfigByType returns the last successfully achieved AdditionalMongodConfigType for the given component. +func (m *MongoDB) GetLastAdditionalMongodConfigByType(configType AdditionalMongodConfigType) (*AdditionalMongodConfig, error) { + lastSpec, err := m.GetLastSpec() + if err != nil || lastSpec == nil { + return &AdditionalMongodConfig{}, err + } + + switch configType { + case ReplicaSetConfig, StandaloneConfig: + return lastSpec.GetAdditionalMongodConfig(), nil + case MongosConfig: + return lastSpec.MongosSpec.GetAdditionalMongodConfig(), nil + case ConfigServerConfig: + return lastSpec.ConfigSrvSpec.GetAdditionalMongodConfig(), nil + case ShardConfig: + return lastSpec.ShardSpec.GetAdditionalMongodConfig(), nil + } + return &AdditionalMongodConfig{}, nil +} + +// +kubebuilder:object:generate=false +type DbSpec interface { + Replicas() int + GetClusterDomain() string + GetMongoDBVersion() string + GetSecurityAuthenticationModes() []string + GetResourceType() ResourceType + IsSecurityTLSConfigEnabled() bool + GetFeatureCompatibilityVersion() *string + GetHorizonConfig() []MongoDBHorizonConfig + GetAdditionalMongodConfig() *AdditionalMongodConfig + GetExternalDomain() *string + GetMemberOptions() []automationconfig.MemberOptions +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type MongoDBList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + Items []MongoDB `json:"items"` +} + +type MongoDBHorizonConfig map[string]string + +type MongoDBConnectivity struct { + // ReplicaSetHorizons holds list of maps of horizons to be configured in each of MongoDB processes. + // Horizons map horizon names to the node addresses for each process in the replicaset, e.g.: + // [ + // { + // "internal": "my-rs-0.my-internal-domain.com:31843", + // "external": "my-rs-0.my-external-domain.com:21467" + // }, + // { + // "internal": "my-rs-1.my-internal-domain.com:31843", + // "external": "my-rs-1.my-external-domain.com:21467" + // }, + // ... + // ] + // The key of each item in the map is an arbitrary, user-chosen string that + // represents the name of the horizon. The value of the item is the host and, + // optionally, the port that this mongod node will be connected to from. + // +optional + ReplicaSetHorizons []MongoDBHorizonConfig `json:"replicaSetHorizons,omitempty"` +} + +type MongoDbStatus struct { + status.Common `json:",inline"` + BackupStatus *BackupStatus `json:"backup,omitempty"` + MongodbShardedClusterSizeConfig `json:",inline"` + Members int `json:"members,omitempty"` + Version string `json:"version"` + Link string `json:"link,omitempty"` + Warnings []status.Warning `json:"warnings,omitempty"` +} + +type BackupMode string + +type BackupStatus struct { + StatusName string `json:"statusName"` +} + +type DbCommonSpec struct { + // +kubebuilder:validation:Pattern=^[0-9]+.[0-9]+.[0-9]+(-.+)?$|^$ + // +kubebuilder:validation:Required + Version string `json:"version"` + FeatureCompatibilityVersion *string `json:"featureCompatibilityVersion,omitempty"` + Agent AgentConfig `json:"agent,omitempty"` + // +kubebuilder:validation:Format="hostname" + ClusterDomain string `json:"clusterDomain,omitempty"` + ConnectionSpec `json:",inline"` + + // DEPRECATED: use ExternalAccessConfiguration instead + // +optional + ExposedExternally bool `json:"exposedExternally,omitempty"` + // ExternalAccessConfiguration provides external access configuration. + // +optional + ExternalAccessConfiguration *ExternalAccessConfiguration `json:"externalAccess,omitempty"` + + Persistent *bool `json:"persistent,omitempty"` + // +kubebuilder:validation:Enum=Standalone;ReplicaSet;ShardedCluster + // +kubebuilder:validation:Required + ResourceType ResourceType `json:"type"` + // +optional + Security *Security `json:"security,omitempty"` + Connectivity *MongoDBConnectivity `json:"connectivity,omitempty"` + Backup *Backup `json:"backup,omitempty"` + + // Prometheus configurations. + // +optional + Prometheus *mdbcv1.Prometheus `json:"prometheus,omitempty"` + + // +optional + // StatefulSetConfiguration provides the statefulset override for each of the cluster's statefulset + // if "StatefulSetConfiguration" is specified at cluster level under "clusterSpecList" that takes precedence over + // the global one + StatefulSetConfiguration *mdbcv1.StatefulSetConfiguration `json:"statefulSet,omitempty"` + + // AdditionalMongodConfig is additional configuration that can be passed to + // each data-bearing mongod at runtime. Uses the same structure as the mongod + // configuration file: + // https://docs.mongodb.com/manual/reference/configuration-options/ + // +kubebuilder:pruning:PreserveUnknownFields + // +optional + AdditionalMongodConfig *AdditionalMongodConfig `json:"additionalMongodConfig,omitempty"` +} + +type MongoDbSpec struct { + // +kubebuilder:pruning:PreserveUnknownFields + DbCommonSpec `json:",inline"` + ShardedClusterSpec `json:",inline"` + MongodbShardedClusterSizeConfig `json:",inline"` + + // Amount of members for this MongoDB Replica Set + Members int `json:"members,omitempty"` + PodSpec *MongoDbPodSpec `json:"podSpec,omitempty"` + // DEPRECATED please use `spec.statefulSet.spec.serviceName` to provide a custom service name. + // this is an optional service, it will get the name "-service" in case not provided + Service string `json:"service,omitempty"` + + // MemberConfig + // +kubebuilder:pruning:PreserveUnknownFields + // +optional + MemberConfig []automationconfig.MemberOptions `json:"memberConfig,omitempty"` +} + +func (s *MongoDbSpec) GetExternalDomain() *string { + if s.ExternalAccessConfiguration != nil { + return s.ExternalAccessConfiguration.ExternalDomain + } + return nil +} + +func (s *MongoDbSpec) GetHorizonConfig() []MongoDBHorizonConfig { + return s.Connectivity.ReplicaSetHorizons +} + +func (s *MongoDbSpec) GetMemberOptions() []automationconfig.MemberOptions { + return s.MemberConfig +} + +type SnapshotSchedule struct { + // Number of hours between snapshots. + // +kubebuilder:validation:Enum=6;8;12;24 + // +optional + SnapshotIntervalHours *int `json:"snapshotIntervalHours,omitempty"` + + // Number of days to keep recent snapshots. + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=365 + // +optional + SnapshotRetentionDays *int `json:"snapshotRetentionDays,omitempty"` + + // Number of days to retain daily snapshots. Setting 0 will disable this rule. + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=365 + // +optional + DailySnapshotRetentionDays *int `json:"dailySnapshotRetentionDays"` + + // Number of weeks to retain weekly snapshots. Setting 0 will disable this rule + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=365 + // +optional + WeeklySnapshotRetentionWeeks *int `json:"weeklySnapshotRetentionWeeks,omitempty"` + + // Number of months to retain weekly snapshots. Setting 0 will disable this rule. + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=36 + // +optional + MonthlySnapshotRetentionMonths *int `json:"monthlySnapshotRetentionMonths,omitempty"` + + // Number of hours in the past for which a point-in-time snapshot can be created. + // +kubebuilder:validation:Enum=1;2;3;4;5;6;7;15;30;60;90;120;180;360 + // +optional + PointInTimeWindowHours *int `json:"pointInTimeWindowHours,omitempty"` + + // Hour of the day to schedule snapshots using a 24-hour clock, in UTC. + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=23 + // +optional + ReferenceHourOfDay *int `json:"referenceHourOfDay,omitempty"` + + // Minute of the hour to schedule snapshots, in UTC. + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=59 + // +optional + ReferenceMinuteOfHour *int `json:"referenceMinuteOfHour,omitempty"` + + // Day of the week when Ops Manager takes a full snapshot. This ensures a recent complete backup. Ops Manager sets the default value to SUNDAY. + // +kubebuilder:validation:Enum=SUNDAY;MONDAY;TUESDAY;WEDNESDAY;THURSDAY;FRIDAY;SATURDAY + // +optional + FullIncrementalDayOfWeek *string `json:"fullIncrementalDayOfWeek,omitempty"` + + // +kubebuilder:validation:Enum=15;30;60 + ClusterCheckpointIntervalMin *int `json:"clusterCheckpointIntervalMin,omitempty"` +} + +// Backup contains configuration options for configuring +// backup for this MongoDB resource +type Backup struct { + // +kubebuilder:validation:Enum=enabled;disabled;terminated + // +optional + Mode BackupMode `json:"mode"` + + // AutoTerminateOnDeletion indicates if the Operator should stop and terminate the Backup before the cleanup, + // when the MongoDB CR is deleted + // +optional + AutoTerminateOnDeletion bool `json:"autoTerminateOnDeletion,omitempty"` + + // +optional + SnapshotSchedule *SnapshotSchedule `json:"snapshotSchedule,omitempty"` + + // Encryption settings + // +optional + Encryption *Encryption `json:"encryption,omitempty"` + + // Assignment Labels set in the Ops Manager + // +optional + AssignmentLabels []string `json:"assignmentLabels,omitempty"` +} + +func (s *Backup) IsKmipEnabled() bool { + if s.Encryption == nil || s.Encryption.Kmip == nil { + return false + } + return true +} + +func (m *Backup) GetKmip() *KmipConfig { + if !m.IsKmipEnabled() { + return nil + } + return m.Encryption.Kmip +} + +// Encryption contains encryption settings +type Encryption struct { + // Kmip corresponds to the KMIP configuration assigned to the Ops Manager Project's configuration. + // +optional + Kmip *KmipConfig `json:"kmip,omitempty"` +} + +// KmipConfig contains Project-level KMIP configuration +type KmipConfig struct { + // KMIP Client configuration + Client v1.KmipClientConfig `json:"client"` +} + +type AgentConfig struct { + // +optional + StartupParameters StartupParameters `json:"startupOptions"` + // +optional + LogLevel LogLevel `json:"logLevel"` + // +optional + MaxLogFileDurationHours int `json:"maxLogFileDurationHours"` +} + +type StartupParameters map[string]string + +func (s StartupParameters) ToCommandLineArgs() string { + var keys []string + for k := range s { + keys = append(keys, k) + } + + // order must be preserved to ensure the same set of command line arguments + // results in the same StatefulSet template spec. + sort.SliceStable(keys, func(i, j int) bool { + return keys[i] < keys[j] + }) + + sb := strings.Builder{} + for _, key := range keys { + if value := s[key]; value != "" { + sb.Write([]byte(fmt.Sprintf(" -%s=%s", key, value))) + } else { + sb.Write([]byte(fmt.Sprintf(" -%s", key))) + } + } + return sb.String() +} + +func (m *MongoDB) DesiredReplicas() int { + return m.Spec.Members +} + +func (m *MongoDB) CurrentReplicas() int { + return m.Status.Members +} + +// GetMongoDBVersion returns the version of the MongoDB. +func (ms MongoDbSpec) GetMongoDBVersion() string { + return ms.Version +} + +func (ms MongoDbSpec) GetClusterDomain() string { + if ms.ClusterDomain != "" { + return ms.ClusterDomain + } + return "cluster.local" +} + +// TODO docs +func (m MongoDbSpec) MinimumMajorVersion() uint64 { + if m.FeatureCompatibilityVersion != nil && *m.FeatureCompatibilityVersion != "" { + fcv := *m.FeatureCompatibilityVersion + + // ignore errors here as the format of FCV/version is handled by CRD validation + semverFcv, _ := semver.Make(fmt.Sprintf("%s.0", fcv)) + return semverFcv.Major + } + semverVersion, _ := semver.Make(m.GetMongoDBVersion()) + return semverVersion.Major +} + +// ProjectConfig contains the configuration expected from the `project` (ConfigMap) under Data. +type ProjectConfig struct { + BaseURL string + ProjectName string + OrgID string + Credentials string + UseCustomCA bool + env.SSLProjectConfig +} + +// Credentials contains the configuration expected from the `credentials` (Secret)` attribute in +// `.spec.credentials`. +type Credentials struct { + // +required + PublicAPIKey string + + // +required + PrivateAPIKey string +} + +type ConfigMapRef struct { + Name string `json:"name,omitempty"` +} + +type PrivateCloudConfig struct { + ConfigMapRef ConfigMapRef `json:"configMapRef,omitempty"` +} + +// ConnectionSpec holds fields required to establish an Ops Manager connection +// note, that the fields are marked as 'omitempty' as otherwise they are shown for AppDB +// which is not good +type ConnectionSpec struct { + SharedConnectionSpec `json:",inline"` + // Name of the Secret holding credentials information + // +kubebuilder:validation:Required + Credentials string `json:"credentials"` +} + +type SharedConnectionSpec struct { + // Transient field - the name of the project. By default, is equal to the name of the resource + // though can be overridden if the ConfigMap specifies a different name + ProjectName string `json:"-"` // ignore when marshalling + + // Dev note: don't reference these two fields directly - use the `getProject` method instead + + OpsManagerConfig *PrivateCloudConfig `json:"opsManager,omitempty"` + CloudManagerConfig *PrivateCloudConfig `json:"cloudManager,omitempty"` + + // FIXME: LogLevel is not a required field for creating an Ops Manager connection, it should not be here. + + // +kubebuilder:validation:Enum=DEBUG;INFO;WARN;ERROR;FATAL + LogLevel LogLevel `json:"logLevel,omitempty"` +} + +type Security struct { + TLSConfig *TLSConfig `json:"tls,omitempty"` + Authentication *Authentication `json:"authentication,omitempty"` + Roles []MongoDbRole `json:"roles,omitempty"` + + // +optional + CertificatesSecretsPrefix string `json:"certsSecretPrefix"` +} + +// MemberCertificateSecretName returns the name of the secret containing the member TLS certs. +func (s Security) MemberCertificateSecretName(defaultName string) string { + if s.CertificatesSecretsPrefix != "" { + return fmt.Sprintf("%s-%s-cert", s.CertificatesSecretsPrefix, defaultName) + } + + // The default behaviour is to use the `defaultname-cert` format + return fmt.Sprintf("%s-cert", defaultName) +} + +func (d DbCommonSpec) GetSecurity() *Security { + if d.Security == nil { + return &Security{} + } + return d.Security +} + +func (d DbCommonSpec) GetExternalDomain() *string { + if d.ExternalAccessConfiguration != nil { + return d.ExternalAccessConfiguration.ExternalDomain + } + return nil +} + +func (d DbCommonSpec) GetAdditionalMongodConfig() *AdditionalMongodConfig { + if d.AdditionalMongodConfig != nil { + return d.AdditionalMongodConfig + } + return &AdditionalMongodConfig{} +} + +func (s *Security) IsTLSEnabled() bool { + if s == nil { + return false + } + if s.TLSConfig != nil { + if s.TLSConfig.Enabled { + return true + } + } + return s.CertificatesSecretsPrefix != "" +} + +// GetAgentMechanism returns the authentication mechanism that the agents will be using. +// The agents will use X509 if it is the only mechanism specified, otherwise they will use SCRAM if specified +// and no auth if no mechanisms exist. +func (s *Security) GetAgentMechanism(currentMechanism string) string { + if s == nil || s.Authentication == nil { + return "" + } + auth := s.Authentication + if !s.Authentication.Enabled { + return "" + } + + if currentMechanism == "MONGODB-X509" { + return util.X509 + } + + // If we arrive here, this should + // ALWAYS be true, as we do not allow + // agents.mode to be empty + // if more than one mode in specified in + // spec.authentication.modes + // The check is done in the validation webhook + if len(s.Authentication.Modes) == 1 { + return s.Authentication.Modes[0] + } + return auth.Agents.Mode +} + +// ShouldUseX509 determines if the deployment should have X509 authentication configured +// whether it was configured explicitly or if it required as it would be performing +// an illegal transition otherwise. +func (s *Security) ShouldUseX509(currentAgentAuthMode string) bool { + return s.GetAgentMechanism(currentAgentAuthMode) == util.X509 +} + +// AgentClientCertificateSecretName returns the name of the Secret that holds the agent +// client TLS certificates. +// If no custom name has been defined, it returns the default one. +func (s Security) AgentClientCertificateSecretName(resourceName string) corev1.SecretKeySelector { + secretName := util.AgentSecretName + + if s.CertificatesSecretsPrefix != "" { + secretName = fmt.Sprintf("%s-%s-%s", s.CertificatesSecretsPrefix, resourceName, util.AgentSecretName) + } + if s.ShouldUseClientCertificates() { + secretName = s.Authentication.Agents.ClientCertificateSecretRefWrap.ClientCertificateSecretRef.Name + } + + return corev1.SecretKeySelector{ + Key: util.AutomationAgentPemSecretKey, + LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, + } +} + +// The customer has set ClientCertificateSecretRef. This signals that client certs are required, +// even when no x509 agent-auth has been enabled. +func (s Security) ShouldUseClientCertificates() bool { + return s.Authentication != nil && s.Authentication.Agents.ClientCertificateSecretRefWrap.ClientCertificateSecretRef.Name != "" +} + +func (s Security) InternalClusterAuthSecretName(defaultName string) string { + secretName := fmt.Sprintf("%s-clusterfile", defaultName) + if s.CertificatesSecretsPrefix != "" { + secretName = fmt.Sprintf("%s-%s", s.CertificatesSecretsPrefix, secretName) + } + return secretName +} + +// RequiresClientTLSAuthentication checks if client TLS authentication is required, depending +// on a set of defined attributes in the MongoDB resource. This can be explicitly set, setting +// `Authentication.RequiresClientTLSAuthentication` to true or implicitly by setting x509 auth +// as the only auth mechanism. +func (s Security) RequiresClientTLSAuthentication() bool { + if s.Authentication == nil { + return false + } + + if len(s.Authentication.Modes) == 1 && stringutil.Contains(s.Authentication.Modes, util.X509) { + return true + } + + return s.Authentication.RequiresClientTLSAuthentication +} + +func (s *Security) ShouldUseLDAP(currentAgentAuthMode string) bool { + return s.GetAgentMechanism(currentAgentAuthMode) == util.LDAP +} + +func (s *Security) GetInternalClusterAuthenticationMode() string { + if s == nil || s.Authentication == nil { + return "" + } + if s.Authentication.InternalCluster != "" { + return strings.ToUpper(s.Authentication.InternalCluster) + } + return "" +} + +// Authentication holds various authentication related settings that affect +// this MongoDB resource. +type Authentication struct { + Enabled bool `json:"enabled"` + Modes []string `json:"modes,omitempty"` + InternalCluster string `json:"internalCluster,omitempty"` + // IgnoreUnknownUsers maps to the inverse of auth.authoritativeSet + IgnoreUnknownUsers bool `json:"ignoreUnknownUsers,omitempty"` + + // LDAP Configuration + // +optional + Ldap *Ldap `json:"ldap,omitempty"` + + // Agents contains authentication configuration properties for the agents + // +optional + Agents AgentAuthentication `json:"agents,omitempty"` + + // Clients should present valid TLS certificates + RequiresClientTLSAuthentication bool `json:"requireClientTLSAuthentication,omitempty"` +} + +type AuthenticationRestriction struct { + ClientSource []string `json:"clientSource,omitempty"` + ServerAddress []string `json:"serverAddress,omitempty"` +} + +type Resource struct { + // +optional + Db string `json:"db"` + // +optional + Collection string `json:"collection"` + Cluster *bool `json:"cluster,omitempty"` +} + +type Privilege struct { + Actions []string `json:"actions"` + Resource Resource `json:"resource"` +} + +type InheritedRole struct { + Db string `json:"db"` + Role string `json:"role"` +} + +type MongoDbRole struct { + Role string `json:"role"` + AuthenticationRestrictions []AuthenticationRestriction `json:"authenticationRestrictions,omitempty"` + Db string `json:"db"` + // +optional + Privileges []Privilege `json:"privileges"` + Roles []InheritedRole `json:"roles,omitempty"` +} + +type AgentAuthentication struct { + // Mode is the desired Authentication mode that the agents will use + Mode string `json:"mode"` + // +optional + AutomationUserName string `json:"automationUserName"` + // +optional + AutomationPasswordSecretRef corev1.SecretKeySelector `json:"automationPasswordSecretRef"` + // +optional + AutomationLdapGroupDN string `json:"automationLdapGroupDN"` + // +optional + // +kubebuilder:pruning:PreserveUnknownFields + ClientCertificateSecretRefWrap ClientCertificateSecretRefWrapper `json:"clientCertificateSecretRef,omitempty"` +} + +// IsX509Enabled determines if X509 is to be enabled at the project level +// it does not necessarily mean that the agents are using X509 authentication +func (a *Authentication) IsX509Enabled() bool { + return stringutil.Contains(a.GetModes(), util.X509) +} + +// IsLDAPEnabled determines if LDAP is to be enabled at the project level +func (a *Authentication) isLDAPEnabled() bool { + return stringutil.Contains(a.GetModes(), util.LDAP) +} + +// GetModes returns the modes of the Authentication instance of an empty +// list if it is nil +func (a *Authentication) GetModes() []string { + if a == nil { + return []string{} + } + return a.Modes +} + +type Ldap struct { + // +optional + Servers []string `json:"servers"` + + // +kubebuilder:validation:Enum=tls;none + // +optional + TransportSecurity *TransportSecurity `json:"transportSecurity"` + // +optional + ValidateLDAPServerConfig *bool `json:"validateLDAPServerConfig"` + + // Allows to point at a ConfigMap/key with a CA file to mount on the Pod + CAConfigMapRef *corev1.ConfigMapKeySelector `json:"caConfigMapRef,omitempty"` + + // +optional + BindQueryUser string `json:"bindQueryUser"` + // +optional + BindQuerySecretRef SecretRef `json:"bindQueryPasswordSecretRef"` + // +optional + AuthzQueryTemplate string `json:"authzQueryTemplate"` + // +optional + UserToDNMapping string `json:"userToDNMapping"` + // +optional + TimeoutMS int `json:"timeoutMS"` + // +optional + UserCacheInvalidationInterval int `json:"userCacheInvalidationInterval"` +} + +type SecretRef struct { + // +kubebuilder:validation:Required + Name string `json:"name"` +} + +type TLSConfig struct { + // DEPRECATED please enable TLS by setting `security.certsSecretPrefix` or `security.tls.secretRef.prefix`. + // Enables TLS for this resource. This will make the operator try to mount a + // Secret with a defined name (-cert). + // This is only used when enabling TLS on a MongoDB resource, and not on the + // AppDB, where TLS is configured by setting `secretRef.Name`. + Enabled bool `json:"enabled,omitempty"` + + AdditionalCertificateDomains []string `json:"additionalCertificateDomains,omitempty"` + + // CA corresponds to a ConfigMap containing an entry for the CA certificate (ca.pem) + // used to validate the certificates created already. + CA string `json:"ca,omitempty"` +} + +func (spec MongoDbSpec) GetTLSConfig() *TLSConfig { + if spec.Security == nil || spec.Security.TLSConfig == nil { + return &TLSConfig{} + } + + return spec.Security.TLSConfig +} + +// when unmarshalling a MongoDB instance, we don't want to have any nil references +// these are replaced with an empty instance to prevent nil references +func (m *MongoDB) UnmarshalJSON(data []byte) error { + type MongoDBJSON *MongoDB + if err := json.Unmarshal(data, (MongoDBJSON)(m)); err != nil { + return err + } + + m.InitDefaults() + + return nil +} + +// GetLastSpec returns the last spec that has successfully be applied. +func (m *MongoDB) GetLastSpec() (*MongoDbSpec, error) { + lastSpecStr := annotations.GetAnnotation(m, util.LastAchievedSpec) + if lastSpecStr == "" { + return nil, nil + } + + lastSpec := MongoDbSpec{} + if err := json.Unmarshal([]byte(lastSpecStr), &lastSpec); err != nil { + return nil, err + } + + conf, err := getMapFromAnnotation(m, util.LastAchievedMongodAdditionalOptions) + if err != nil { + return nil, err + } + if conf != nil { + lastSpec.AdditionalMongodConfig = &AdditionalMongodConfig{object: conf} + } + + conf, err = getMapFromAnnotation(m, util.LastAchievedMongodAdditionalMongosOptions) + if err != nil { + return nil, err + } + if conf != nil { + lastSpec.MongosSpec.AdditionalMongodConfig = &AdditionalMongodConfig{object: conf} + } + + conf, err = getMapFromAnnotation(m, util.LastAchievedMongodAdditionalConfigServerOptions) + if err != nil { + return nil, err + } + if conf != nil { + lastSpec.ConfigSrvSpec.AdditionalMongodConfig = &AdditionalMongodConfig{object: conf} + } + + conf, err = getMapFromAnnotation(m, util.LastAchievedMongodAdditionalShardOptions) + if err != nil { + return nil, err + } + if conf != nil { + lastSpec.ShardSpec.AdditionalMongodConfig = &AdditionalMongodConfig{object: conf} + } + return &lastSpec, nil +} + +// getMapFromAnnotation returns the additional config map from a given annotation. +func getMapFromAnnotation(m client.Object, annotationKey string) (map[string]interface{}, error) { + additionConfigStr := annotations.GetAnnotation(m, annotationKey) + if additionConfigStr != "" { + var conf map[string]interface{} + if err := json.Unmarshal([]byte(additionConfigStr), &conf); err != nil { + return nil, err + } + return conf, nil + } + return nil, nil +} + +func (m *MongoDB) ServiceName() string { + if m.Spec.StatefulSetConfiguration != nil { + svc := m.Spec.StatefulSetConfiguration.SpecWrapper.Spec.ServiceName + + if svc != "" { + return svc + } + } + + if m.Spec.Service == "" { + return dns.GetServiceName(m.GetName()) + } + return m.Spec.Service +} + +func (m *MongoDB) ConfigSrvServiceName() string { + return m.Name + "-cs" +} + +func (m *MongoDB) ShardServiceName() string { + return m.Name + "-sh" +} + +func (m *MongoDB) MongosRsName() string { + return m.Name + "-mongos" +} + +func (m *MongoDB) ConfigRsName() string { + return m.Name + "-config" +} + +func (m *MongoDB) ShardRsName(i int) string { + // Unfortunately the pattern used by OM (name_idx) doesn't work as Kubernetes doesn't create the stateful set with an + // exception: "a DNS-1123 subdomain must consist of lower case alphanumeric characters, '-' or '.'" + return fmt.Sprintf("%s-%d", m.Name, i) +} + +func (m *MongoDB) IsLDAPEnabled() bool { + if m.Spec.Security == nil || m.Spec.Security.Authentication == nil { + return false + } + return stringutil.Contains(m.Spec.Security.Authentication.Modes, util.LDAP) +} + +func (m *MongoDB) UpdateStatus(phase status.Phase, statusOptions ...status.Option) { + m.Status.UpdateCommonFields(phase, m.GetGeneration(), statusOptions...) + + if option, exists := status.GetOption(statusOptions, status.BackupStatusOption{}); exists { + if m.Status.BackupStatus == nil { + m.Status.BackupStatus = &BackupStatus{} + } + m.Status.BackupStatus.StatusName = option.(status.BackupStatusOption).Value().(string) + } + + if option, exists := status.GetOption(statusOptions, status.WarningsOption{}); exists { + m.Status.Warnings = append(m.Status.Warnings, option.(status.WarningsOption).Warnings...) + } + if option, exists := status.GetOption(statusOptions, status.BaseUrlOption{}); exists { + m.Status.Link = option.(status.BaseUrlOption).BaseUrl + } + switch m.Spec.ResourceType { + case ReplicaSet: + if option, exists := status.GetOption(statusOptions, status.ReplicaSetMembersOption{}); exists { + m.Status.Members = option.(status.ReplicaSetMembersOption).Members + } + case ShardedCluster: + if option, exists := status.GetOption(statusOptions, status.ShardedClusterConfigServerOption{}); exists { + m.Status.ConfigServerCount = option.(status.ShardedClusterConfigServerOption).Members + } + if option, exists := status.GetOption(statusOptions, status.ShardedClusterMongodsPerShardCountOption{}); exists { + m.Status.MongodsPerShardCount = option.(status.ShardedClusterMongodsPerShardCountOption).Members + } + if option, exists := status.GetOption(statusOptions, status.ShardedClusterMongosOption{}); exists { + m.Status.MongosCount = option.(status.ShardedClusterMongosOption).Members + } + } + + if phase == status.PhaseRunning { + m.Status.Version = m.Spec.Version + m.Status.Message = "" + + switch m.Spec.ResourceType { + case ShardedCluster: + m.Status.ShardCount = m.Spec.ShardCount + } + } +} + +func (m *MongoDB) SetWarnings(warnings []status.Warning, _ ...status.Option) { + m.Status.Warnings = warnings +} + +func (m *MongoDB) AddWarningIfNotExists(warning status.Warning) { + m.Status.Warnings = status.Warnings(m.Status.Warnings).AddIfNotExists(warning) +} + +func (m *MongoDB) GetPlural() string { + return "mongodb" +} + +func (m *MongoDB) GetStatus(...status.Option) interface{} { + return m.Status +} + +func (m *MongoDB) GetStatusPath(...status.Option) string { + return "/status" +} + +// GetProject returns the name of the ConfigMap containing the information about connection to OM/CM, returns empty string if +// neither CloudManager not OpsManager configmap is set +func (c *ConnectionSpec) GetProject() string { + // the contract is that either ops manager or cloud manager must be provided - the controller must validate this + if c.OpsManagerConfig.ConfigMapRef.Name != "" { + return c.OpsManagerConfig.ConfigMapRef.Name + } + if c.CloudManagerConfig.ConfigMapRef.Name != "" { + return c.CloudManagerConfig.ConfigMapRef.Name + } + return "" +} + +// InitDefaults makes sure the MongoDB resource has correct state after initialization: +// - prevents any references from having nil values. +// - makes sure the spec is in correct state +// +// should not be called directly, used in tests and unmarshalling +func (m *MongoDB) InitDefaults() { + // al resources have a pod spec + if m.Spec.PodSpec == nil { + m.Spec.PodSpec = NewMongoDbPodSpec() + } + + if m.Spec.ResourceType == ShardedCluster { + if m.Spec.ConfigSrvPodSpec == nil { + m.Spec.ConfigSrvPodSpec = NewMongoDbPodSpec() + } + if m.Spec.MongosPodSpec == nil { + m.Spec.MongosPodSpec = NewMongoDbPodSpec() + } + if m.Spec.ShardPodSpec == nil { + m.Spec.ShardPodSpec = NewMongoDbPodSpec() + } + if m.Spec.ConfigSrvSpec == nil { + m.Spec.ConfigSrvSpec = &ShardedClusterComponentSpec{} + } + if m.Spec.MongosSpec == nil { + m.Spec.MongosSpec = &ShardedClusterComponentSpec{} + } + if m.Spec.ShardSpec == nil { + m.Spec.ShardSpec = &ShardedClusterComponentSpec{} + } + + } + + if m.Spec.Connectivity == nil { + m.Spec.Connectivity = NewConnectivity() + } + + m.Spec.Security = EnsureSecurity(m.Spec.Security) + + if m.Spec.OpsManagerConfig == nil { + m.Spec.OpsManagerConfig = NewOpsManagerConfig() + } + + if m.Spec.CloudManagerConfig == nil { + m.Spec.CloudManagerConfig = NewOpsManagerConfig() + } + + // ProjectName defaults to the name of the resource + m.Spec.ProjectName = m.Name + + // External Access old API compatibility code + if m.Spec.ExposedExternally { + if m.Spec.ExternalAccessConfiguration == nil { + m.Spec.ExternalAccessConfiguration = &ExternalAccessConfiguration{} + } + } +} + +func (m *MongoDB) ObjectKey() client.ObjectKey { + return kube.ObjectKey(m.Namespace, m.Name) +} + +func (m *MongoDB) GetLDAP(password, caContents string) *ldap.Ldap { + if !m.IsLDAPEnabled() { + return nil + } + + mdbLdap := m.Spec.Security.Authentication.Ldap + transportSecurity := GetTransportSecurity(mdbLdap) + + validateServerConfig := true + if mdbLdap.ValidateLDAPServerConfig != nil { + validateServerConfig = *mdbLdap.ValidateLDAPServerConfig + } + + return &ldap.Ldap{ + BindQueryUser: mdbLdap.BindQueryUser, + BindQueryPassword: password, + Servers: strings.Join(mdbLdap.Servers, ","), + TransportSecurity: string(transportSecurity), + CaFileContents: caContents, + ValidateLDAPServerConfig: validateServerConfig, + + // Related to LDAP Authorization + AuthzQueryTemplate: mdbLdap.AuthzQueryTemplate, + UserToDnMapping: mdbLdap.UserToDNMapping, + + // TODO: Enable LDAP SASL bind method + BindMethod: "simple", + BindSaslMechanisms: "", + + TimeoutMS: mdbLdap.TimeoutMS, + UserCacheInvalidationInterval: mdbLdap.UserCacheInvalidationInterval, + } + +} + +// ExternalAccessConfiguration holds the custom Service override that will be merged into the operator created one. +type ExternalAccessConfiguration struct { + // Provides a way to override the default (NodePort) Service + // +optional + ExternalService ExternalServiceConfiguration `json:"externalService,omitempty"` + + // An external domain that is used for exposing MongoDB to the outside world. + // +optional + ExternalDomain *string `json:"externalDomain,omitempty"` +} + +// ExternalServiceConfiguration is a wrapper for the Service spec object. +type ExternalServiceConfiguration struct { + // A wrapper for the Service spec object. + // +optional + // +kubebuilder:pruning:PreserveUnknownFields + SpecWrapper *ServiceSpecWrapper `json:"spec"` + + // A map of annotations that shall be added to the externally available Service. + // +optional + Annotations map[string]string `json:"annotations,omitempty"` +} + +func GetTransportSecurity(mdbLdap *Ldap) TransportSecurity { + transportSecurity := TransportSecurityNone + if mdbLdap.TransportSecurity != nil && strings.ToLower(string(*mdbLdap.TransportSecurity)) != "none" { + transportSecurity = TransportSecurityTLS + } + return transportSecurity +} + +type MongoDbPodSpec struct { + ContainerResourceRequirements `json:"-"` + + // +kubebuilder:pruning:PreserveUnknownFields + PodTemplateWrapper PodTemplateSpecWrapper `json:"podTemplate,omitempty"` + // Note, this field is not serialized in the CRD, it's only present here because of the + // way we currently set defaults for this field in the operator, similar to "ContainerResourceRequirements" + + PodAntiAffinityTopologyKey string `json:"-"` + + // Note, that this field is used by MongoDB resources only, let's keep it here for simplicity + + Persistence *Persistence `json:"persistence,omitempty"` +} + +type ContainerResourceRequirements struct { + CpuLimit string + CpuRequests string + MemoryLimit string + MemoryRequests string +} + +// This is a struct providing the opportunity to customize the pod created under the hood. +// It naturally delegates to inner object and provides some defaults that can be overriden in each specific case +// TODO remove in favor or 'StatefulSetHelper.setPodSpec(podSpec, defaultPodSpec)' +type PodSpecWrapper struct { + MongoDbPodSpec + // These are the default values, unfortunately Golang doesn't provide the possibility to inline default values into + // structs so use the operator.NewDefaultPodSpec constructor for this + Default MongoDbPodSpec +} + +type Persistence struct { + SingleConfig *PersistenceConfig `json:"single,omitempty"` + MultipleConfig *MultiplePersistenceConfig `json:"multiple,omitempty"` +} + +type MultiplePersistenceConfig struct { + Data *PersistenceConfig `json:"data,omitempty"` + Journal *PersistenceConfig `json:"journal,omitempty"` + Logs *PersistenceConfig `json:"logs,omitempty"` +} + +type PersistenceConfig struct { + Storage string `json:"storage,omitempty"` + StorageClass *string `json:"storageClass,omitempty"` + + // +kubebuilder:pruning:PreserveUnknownFields + LabelSelector *LabelSelectorWrapper `json:"labelSelector,omitempty"` +} + +func (p PodSpecWrapper) GetCpuOrDefault() string { + if p.CpuLimit == "" && p.CpuRequests == "" { + return p.Default.CpuLimit + } + return p.CpuLimit + +} + +func (p PodSpecWrapper) GetMemoryOrDefault() string { + // We don't set default if either Memory requests or Memory limits are specified by the User + if p.ContainerResourceRequirements.MemoryLimit == "" && p.ContainerResourceRequirements.MemoryRequests == "" { + return p.Default.ContainerResourceRequirements.MemoryLimit + } + return p.ContainerResourceRequirements.MemoryLimit +} + +func (p PodSpecWrapper) GetCpuRequestsOrDefault() string { + if p.CpuRequests == "" && p.CpuLimit == "" { + return p.Default.CpuRequests + } + return p.CpuRequests +} + +func (p PodSpecWrapper) GetMemoryRequestsOrDefault() string { + // We don't set default if either Memory requests or Memory limits are specified by the User + // otherwise it's possible to get failed Statefulset (e.g. the user specified limits of 200M but we default + //requests to 500M though requests must be less than limits) + if p.MemoryRequests == "" && p.MemoryLimit == "" { + return p.Default.MemoryRequests + } + return p.MemoryRequests +} + +func (p PodSpecWrapper) GetTopologyKeyOrDefault() string { + if p.PodAntiAffinityTopologyKey == "" { + return p.Default.PodAntiAffinityTopologyKey + } + return p.PodAntiAffinityTopologyKey +} + +func (p PodSpecWrapper) SetCpu(cpu string) PodSpecWrapper { + p.CpuLimit = cpu + return p +} + +func (p PodSpecWrapper) SetMemory(memory string) PodSpecWrapper { + p.MemoryLimit = memory + return p +} + +func (p PodSpecWrapper) SetTopology(topology string) PodSpecWrapper { + p.PodAntiAffinityTopologyKey = topology + return p +} + +func GetStorageOrDefault(config *PersistenceConfig, defaultConfig PersistenceConfig) string { + if config == nil || config.Storage == "" { + return defaultConfig.Storage + } + return config.Storage +} + +// Create a MongoDbPodSpec reference without any nil references +// used to initialize any MongoDbPodSpec fields with valid values +// in order to prevent panicking at runtime. +func NewMongoDbPodSpec() *MongoDbPodSpec { + return &MongoDbPodSpec{} +} + +// Replicas returns the number of "user facing" replicas of the MongoDB resource. This method can be used for +// constructing the mongodb URL for example. +// 'Members' would be a more consistent function but go doesn't allow to have the same +func (spec MongoDbSpec) Replicas() int { + var replicasCount int + switch spec.ResourceType { + case Standalone: + replicasCount = 1 + case ReplicaSet: + replicasCount = spec.Members + case ShardedCluster: + replicasCount = spec.MongosCount + default: + panic("Unknown type of resource!") + } + return replicasCount +} + +func (m MongoDbSpec) GetSecurityAuthenticationModes() []string { + return m.GetSecurity().Authentication.GetModes() +} + +func (m MongoDbSpec) GetResourceType() ResourceType { + return m.ResourceType +} + +func (d DbCommonSpec) IsSecurityTLSConfigEnabled() bool { + return d.GetSecurity().IsTLSEnabled() +} + +func (m MongoDbSpec) GetFeatureCompatibilityVersion() *string { + return m.FeatureCompatibilityVersion +} + +func NewConnectivity() *MongoDBConnectivity { + return &MongoDBConnectivity{} +} + +// PrivateCloudConfig returns and empty `PrivateCloudConfig` object +func NewOpsManagerConfig() *PrivateCloudConfig { + return &PrivateCloudConfig{} +} + +func EnsureSecurity(sec *Security) *Security { + if sec == nil { + sec = newSecurity() + } + if sec.TLSConfig == nil { + sec.TLSConfig = &TLSConfig{} + } + if sec.Roles == nil { + sec.Roles = make([]MongoDbRole, 0) + } + return sec +} + +func newAuthentication() *Authentication { + return &Authentication{Modes: []string{}} +} + +func newSecurity() *Security { + return &Security{TLSConfig: &TLSConfig{}} +} + +// BuildConnectionString returns a string with a connection string for this resource. +func (m *MongoDB) BuildConnectionString(username, password string, scheme connectionstring.Scheme, connectionParams map[string]string) string { + name := m.Name + if m.Spec.ResourceType == ShardedCluster { + name = m.MongosRsName() + } + builder := connectionstring.Builder(). + SetName(name). + SetNamespace(m.Namespace). + SetUsername(username). + SetPassword(password). + SetReplicas(m.Spec.Replicas()). + SetService(m.ServiceName()). + SetPort(m.Spec.GetAdditionalMongodConfig().GetPortOrDefault()). + SetVersion(m.Spec.GetMongoDBVersion()). + SetAuthenticationModes(m.Spec.GetSecurityAuthenticationModes()). + SetClusterDomain(m.Spec.GetClusterDomain()). + SetIsReplicaSet(m.Spec.ResourceType == ReplicaSet). + SetIsTLSEnabled(m.Spec.IsSecurityTLSConfigEnabled()). + SetConnectionParams(connectionParams). + SetScheme(scheme) + + return builder.Build() +} + +func (m *MongoDB) GetAuthenticationModes() []string { + return m.Spec.Security.Authentication.GetModes() +} diff --git a/api/v1/mdb/mongodb_types_test.go b/api/v1/mdb/mongodb_types_test.go new file mode 100644 index 000000000..422c7ac42 --- /dev/null +++ b/api/v1/mdb/mongodb_types_test.go @@ -0,0 +1,356 @@ +package mdb + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/10gen/ops-manager-kubernetes/api/v1/status" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/connectionstring" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" + "github.com/stretchr/testify/assert" +) + +func TestEnsureSecurity_WithAllNilValues(t *testing.T) { + spec := &MongoDbSpec{ + DbCommonSpec: DbCommonSpec{ + Security: nil, + }, + } + + spec.Security = EnsureSecurity(spec.Security) + assert.NotNil(t, spec.Security) + assert.NotNil(t, spec.Security.TLSConfig) +} + +func TestEnsureSecurity_WithNilTlsConfig(t *testing.T) { + spec := &MongoDbSpec{DbCommonSpec: DbCommonSpec{Security: &Security{TLSConfig: nil, Authentication: &Authentication{}}}} + spec.Security = EnsureSecurity(spec.Security) + assert.NotNil(t, spec.Security) + assert.NotNil(t, spec.Security.TLSConfig) +} + +func TestEnsureSecurity_EmptySpec(t *testing.T) { + spec := &MongoDbSpec{} + spec.Security = EnsureSecurity(spec.Security) + assert.NotNil(t, spec.Security) + assert.NotNil(t, spec.Security.TLSConfig) +} + +func TestGetAgentAuthentication(t *testing.T) { + sec := newSecurity() + sec.Authentication = newAuthentication() + sec.Authentication.Agents.Mode = "SCRAM" + assert.Len(t, sec.Authentication.Modes, 0) + assert.Empty(t, sec.GetAgentMechanism("")) + assert.Equal(t, "", sec.GetAgentMechanism("MONGODB-X509")) + + sec.Authentication.Enabled = true + sec.Authentication.Modes = append(sec.Authentication.Modes, util.X509) + assert.Equal(t, util.X509, sec.GetAgentMechanism("MONGODB-X509"), "if x509 was enabled before, it needs to stay as is") + + sec.Authentication.Modes = append(sec.Authentication.Modes, util.SCRAM) + assert.Equal(t, util.SCRAM, sec.GetAgentMechanism("SCRAM-SHA-256"), "if scram was enabled, scram will be chosen") + + sec.Authentication.Modes = append(sec.Authentication.Modes, util.SCRAMSHA1) + assert.Equal(t, util.SCRAM, sec.GetAgentMechanism("SCRAM-SHA-256"), "Adding SCRAM-SHA-1 doesn't change the default") + + sec.Authentication.Agents.Mode = "X509" + assert.Equal(t, util.X509, sec.GetAgentMechanism("SCRAM-SHA-256"), "transitioning from SCRAM -> X509 is allowed") +} + +func TestMinimumMajorVersion(t *testing.T) { + mdbSpec := MongoDbSpec{ + DbCommonSpec: DbCommonSpec{ + Version: "3.6.0-ent", + FeatureCompatibilityVersion: nil, + }, + } + + assert.Equal(t, mdbSpec.MinimumMajorVersion(), uint64(3)) + + mdbSpec = MongoDbSpec{ + DbCommonSpec: DbCommonSpec{ + Version: "4.0.0-ent", + FeatureCompatibilityVersion: stringutil.Ref("3.6"), + }, + } + + assert.Equal(t, mdbSpec.MinimumMajorVersion(), uint64(3)) + + mdbSpec = MongoDbSpec{ + DbCommonSpec: DbCommonSpec{ + Version: "4.0.0", + FeatureCompatibilityVersion: stringutil.Ref("3.6"), + }, + } + + assert.Equal(t, mdbSpec.MinimumMajorVersion(), uint64(3)) +} + +func TestMongoDB_ConnectionURL_NotSecure(t *testing.T) { + rs := NewReplicaSetBuilder().SetMembers(3).Build() + + var cnx string + cnx = rs.BuildConnectionString("", "", connectionstring.SchemeMongoDB, nil) + assert.Equal(t, "mongodb://test-mdb-0.test-mdb-svc.testNS.svc.cluster.local:27017,"+ + "test-mdb-1.test-mdb-svc.testNS.svc.cluster.local:27017,test-mdb-2.test-mdb-svc.testNS.svc.cluster.local:27017/"+ + "?connectTimeoutMS=20000&replicaSet=test-mdb&serverSelectionTimeoutMS=20000", + cnx) + + // Connection parameters. The default one is overridden + cnx = rs.BuildConnectionString("", "", connectionstring.SchemeMongoDB, map[string]string{"connectTimeoutMS": "30000", "readPreference": "secondary"}) + assert.Equal(t, "mongodb://test-mdb-0.test-mdb-svc.testNS.svc.cluster.local:27017,"+ + "test-mdb-1.test-mdb-svc.testNS.svc.cluster.local:27017,test-mdb-2.test-mdb-svc.testNS.svc.cluster.local:27017/"+ + "?connectTimeoutMS=30000&readPreference=secondary&replicaSet=test-mdb&serverSelectionTimeoutMS=20000", + cnx) + + // 2 members, custom cluster name + rs = NewReplicaSetBuilder().SetName("paymentsDb").SetMembers(2).SetClusterDomain("company.domain.net").Build() + cnx = rs.BuildConnectionString("", "", connectionstring.SchemeMongoDB, nil) + assert.Equal(t, "mongodb://paymentsDb-0.paymentsDb-svc.testNS.svc.company.domain.net:27017,"+ + "paymentsDb-1.paymentsDb-svc.testNS.svc.company.domain.net:27017/?connectTimeoutMS=20000&replicaSet=paymentsDb"+ + "&serverSelectionTimeoutMS=20000", + cnx) + + // Sharded cluster + sc := NewClusterBuilder().SetName("contractsDb").SetNamespace("ns").Build() + cnx = sc.BuildConnectionString("", "", connectionstring.SchemeMongoDB, nil) + assert.Equal(t, "mongodb://contractsDb-mongos-0.contractsDb-svc.ns.svc.cluster.local:27017,"+ + "contractsDb-mongos-1.contractsDb-svc.ns.svc.cluster.local:27017/"+ + "?connectTimeoutMS=20000&serverSelectionTimeoutMS=20000", + cnx) + + // Standalone + st := NewStandaloneBuilder().SetName("foo").Build() + cnx = st.BuildConnectionString("", "", connectionstring.SchemeMongoDB, nil) + assert.Equal(t, "mongodb://foo-0.foo-svc.testNS.svc.cluster.local:27017/?"+ + "connectTimeoutMS=20000&serverSelectionTimeoutMS=20000", + cnx) + +} + +func TestMongoDB_ConnectionURL_Secure(t *testing.T) { + var cnx string + + // Only tls enabled, no auth + rs := NewReplicaSetBuilder().SetSecurityTLSEnabled().Build() + cnx = rs.BuildConnectionString("", "", connectionstring.SchemeMongoDB, nil) + assert.Equal(t, "mongodb://test-mdb-0.test-mdb-svc.testNS.svc.cluster.local:27017,"+ + "test-mdb-1.test-mdb-svc.testNS.svc.cluster.local:27017,test-mdb-2.test-mdb-svc.testNS.svc.cluster.local:27017/?"+ + "connectTimeoutMS=20000&replicaSet=test-mdb&serverSelectionTimeoutMS=20000&ssl=true", + cnx) + + // New version of Mongodb -> SCRAM-SHA-256 + rs = NewReplicaSetBuilder().SetMembers(2).SetSecurityTLSEnabled().EnableAuth([]string{util.SCRAM}).Build() + cnx = rs.BuildConnectionString("the_user", "the_passwd", connectionstring.SchemeMongoDB, nil) + assert.Equal(t, "mongodb://the_user:the_passwd@test-mdb-0.test-mdb-svc.testNS.svc.cluster.local:27017,"+ + "test-mdb-1.test-mdb-svc.testNS.svc.cluster.local:27017/?authMechanism=SCRAM-SHA-256&authSource=admin&"+ + "connectTimeoutMS=20000&replicaSet=test-mdb&serverSelectionTimeoutMS=20000&ssl=true", + cnx) + + // Old version of Mongodb -> SCRAM-SHA-1. X509 is a second authentication method - user & password are still appended + rs = NewReplicaSetBuilder().SetMembers(2).SetVersion("3.6.1").EnableAuth([]string{util.SCRAM, util.X509}).Build() + cnx = rs.BuildConnectionString("the_user", "the_passwd", connectionstring.SchemeMongoDB, nil) + assert.Equal(t, "mongodb://the_user:the_passwd@test-mdb-0.test-mdb-svc.testNS.svc.cluster.local:27017,"+ + "test-mdb-1.test-mdb-svc.testNS.svc.cluster.local:27017/?authMechanism=SCRAM-SHA-1&authSource=admin&"+ + "connectTimeoutMS=20000&replicaSet=test-mdb&serverSelectionTimeoutMS=20000", + cnx) + + // Special symbols in user/password must be encoded + rs = NewReplicaSetBuilder().SetMembers(2).EnableAuth([]string{util.SCRAM}).Build() + cnx = rs.BuildConnectionString("user/@", "pwd#!@", connectionstring.SchemeMongoDB, nil) + assert.Equal(t, "mongodb://user%2F%40:pwd%23%21%40@test-mdb-0.test-mdb-svc.testNS.svc.cluster.local:27017,"+ + "test-mdb-1.test-mdb-svc.testNS.svc.cluster.local:27017/?authMechanism=SCRAM-SHA-256&authSource=admin&"+ + "connectTimeoutMS=20000&replicaSet=test-mdb&serverSelectionTimeoutMS=20000", + cnx) + + // Caller can override any connection parameters, e.g."authMechanism" + rs = NewReplicaSetBuilder().SetMembers(2).EnableAuth([]string{util.SCRAM}).Build() + cnx = rs.BuildConnectionString("the_user", "the_passwd", connectionstring.SchemeMongoDB, map[string]string{"authMechanism": "SCRAM-SHA-1"}) + assert.Equal(t, "mongodb://the_user:the_passwd@test-mdb-0.test-mdb-svc.testNS.svc.cluster.local:27017,"+ + "test-mdb-1.test-mdb-svc.testNS.svc.cluster.local:27017/?authMechanism=SCRAM-SHA-1&authSource=admin&"+ + "connectTimeoutMS=20000&replicaSet=test-mdb&serverSelectionTimeoutMS=20000", + cnx) + + // X509 -> no user/password in the url. It's possible to pass user/password in the params though + rs = NewReplicaSetBuilder().SetMembers(2).EnableAuth([]string{util.X509}).Build() + cnx = rs.BuildConnectionString("the_user", "the_passwd", connectionstring.SchemeMongoDB, nil) + assert.Equal(t, "mongodb://test-mdb-0.test-mdb-svc.testNS.svc.cluster.local:27017,"+ + "test-mdb-1.test-mdb-svc.testNS.svc.cluster.local:27017/?connectTimeoutMS=20000&replicaSet=test-mdb&"+ + "serverSelectionTimeoutMS=20000", cnx) + + // username + password must be provided if scram is enabled + rs = NewReplicaSetBuilder().SetMembers(2).EnableAuth([]string{util.SCRAM}).Build() + cnx = rs.BuildConnectionString("the_user", "", connectionstring.SchemeMongoDB, nil) + assert.Equal(t, "mongodb://test-mdb-0.test-mdb-svc.testNS.svc.cluster.local:27017,"+ + "test-mdb-1.test-mdb-svc.testNS.svc.cluster.local:27017/?authMechanism=SCRAM-SHA-256&authSource=admin&"+ + "connectTimeoutMS=20000&replicaSet=test-mdb&serverSelectionTimeoutMS=20000", + cnx) + + cnx = rs.BuildConnectionString("", "the_password", connectionstring.SchemeMongoDB, nil) + assert.Equal(t, "mongodb://test-mdb-0.test-mdb-svc.testNS.svc.cluster.local:27017,"+ + "test-mdb-1.test-mdb-svc.testNS.svc.cluster.local:27017/?authMechanism=SCRAM-SHA-256&authSource=admin&"+ + "connectTimeoutMS=20000&replicaSet=test-mdb&serverSelectionTimeoutMS=20000", + cnx) + + cnx = rs.BuildConnectionString("", "", connectionstring.SchemeMongoDB, nil) + assert.Equal(t, "mongodb://test-mdb-0.test-mdb-svc.testNS.svc.cluster.local:27017,"+ + "test-mdb-1.test-mdb-svc.testNS.svc.cluster.local:27017/?authMechanism=SCRAM-SHA-256&authSource=admin&"+ + "connectTimeoutMS=20000&replicaSet=test-mdb&serverSelectionTimeoutMS=20000", + cnx) +} + +func TestMongoDB_AddWarningIfNotExists(t *testing.T) { + resource := &MongoDB{} + resource.AddWarningIfNotExists("my test warning") + resource.AddWarningIfNotExists("my test warning") + resource.AddWarningIfNotExists("my other test warning") + assert.Equal(t, []status.Warning{"my test warning;", "my other test warning"}, resource.Status.Warnings) +} + +func TestMongoDB_IsSecurityTLSConfigEnabled(t *testing.T) { + rs := NewReplicaSetBuilder().Build() + tests := []struct { + name string + security *Security + expected bool + }{ + { + name: "TLS is not enabled when Security is nil", + security: nil, + expected: false, + }, + { + name: "TLS is not enabled when TLSConfig is nil", + security: &Security{}, + expected: false, + }, + { + name: "TLS is enabled when CertificatesSecretsPrefix is specified", + security: &Security{ + CertificatesSecretsPrefix: "prefix", + }, + expected: true, + }, + { + name: "TLS is enabled when TLSConfig fields are specified and CertificatesSecretsPrefix is specified", + security: &Security{ + CertificatesSecretsPrefix: "prefix", + TLSConfig: &TLSConfig{ + CA: "issuer-ca", + }, + }, + expected: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rs.Spec.Security = tc.security + assert.Equal(t, tc.expected, rs.GetSecurity().IsTLSEnabled()) + }) + } + rs.GetSpec().IsSecurityTLSConfigEnabled() +} + +func TestMemberCertificateSecretName(t *testing.T) { + rs := NewReplicaSetBuilder().SetSecurityTLSEnabled().Build() + + // If nothing is specified, we return -cert + assert.Equal(t, fmt.Sprintf("%s-cert", rs.Name), rs.GetSecurity().MemberCertificateSecretName(rs.Name)) + + // If the top-level prefix is specified, we use it + rs.Spec.Security.CertificatesSecretsPrefix = "top-level-prefix" + assert.Equal(t, fmt.Sprintf("top-level-prefix-%s-cert", rs.Name), rs.GetSecurity().MemberCertificateSecretName(rs.Name)) +} + +func TestAgentClientCertificateSecretName(t *testing.T) { + rs := NewReplicaSetBuilder().SetSecurityTLSEnabled().EnableAuth([]string{util.X509}).Build() + + // Default is the hardcoded "agent-certs" + assert.Equal(t, util.AgentSecretName, rs.GetSecurity().AgentClientCertificateSecretName(rs.Name).LocalObjectReference.Name) + + // If the top-level prefix is there, we use it + rs.Spec.Security.CertificatesSecretsPrefix = "prefix" + assert.Equal(t, fmt.Sprintf("prefix-%s-%s", rs.Name, util.AgentSecretName), rs.GetSecurity().AgentClientCertificateSecretName(rs.Name).LocalObjectReference.Name) + + // If the name is provided (deprecated) we return it + rs.GetSecurity().Authentication.Agents.ClientCertificateSecretRefWrap.ClientCertificateSecretRef.Name = "foo" + assert.Equal(t, "foo", rs.GetSecurity().AgentClientCertificateSecretName(rs.Name).LocalObjectReference.Name) + +} + +func TestInternalClusterAuthSecretName(t *testing.T) { + rs := NewReplicaSetBuilder().SetSecurityTLSEnabled().Build() + + // Default is -clusterfile + assert.Equal(t, fmt.Sprintf("%s-clusterfile", rs.Name), rs.GetSecurity().InternalClusterAuthSecretName(rs.Name)) + + // IF there is a prefix, use it + rs.Spec.Security.CertificatesSecretsPrefix = "prefix" + assert.Equal(t, fmt.Sprintf("prefix-%s-clusterfile", rs.Name), rs.GetSecurity().InternalClusterAuthSecretName(rs.Name)) + +} + +func TestGetTransportSecurity(t *testing.T) { + someTLS := TransportSecurity("SomeTLS") + none := TransportSecurity("NONE") + noneUpper := TransportSecurity("none") + + tests := []struct { + name string + mdbLdap *Ldap + want TransportSecurity + }{ + { + name: "enabling transport security", + mdbLdap: &Ldap{ + TransportSecurity: &someTLS, + }, + want: TransportSecurityTLS, + }, + { + name: "no transport set", + mdbLdap: &Ldap{}, + want: TransportSecurityNone, + }, + { + name: "none set", + mdbLdap: &Ldap{ + TransportSecurity: &none, + }, + want: TransportSecurityNone, + }, + { + name: "NONE set", + mdbLdap: &Ldap{ + TransportSecurity: &noneUpper, + }, + want: TransportSecurityNone, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, GetTransportSecurity(tt.mdbLdap), "GetTransportSecurity(%v)", tt.mdbLdap) + }) + } +} + +func TestAdditionalMongodConfigMarshalJSON(t *testing.T) { + mdb := MongoDB{Spec: MongoDbSpec{DbCommonSpec: DbCommonSpec{Version: "4.2.1"}}} + mdb.InitDefaults() + mdb.Spec.AdditionalMongodConfig = &AdditionalMongodConfig{object: map[string]interface{}{"net": map[string]interface{}{"port": "30000"}}} + + marshal, err := json.Marshal(mdb.Spec) + assert.NoError(t, err) + + unmarshalledSpec := MongoDbSpec{} + + err = json.Unmarshal(marshal, &unmarshalledSpec) + assert.NoError(t, err) + + expected := mdb.Spec.GetAdditionalMongodConfig().ToMap() + actual := unmarshalledSpec.AdditionalMongodConfig.ToMap() + assert.Equal(t, expected, actual) +} diff --git a/api/v1/mdb/mongodb_validation.go b/api/v1/mdb/mongodb_validation.go new file mode 100644 index 000000000..aadcac7c0 --- /dev/null +++ b/api/v1/mdb/mongodb_validation.go @@ -0,0 +1,251 @@ +package mdb + +import ( + "errors" + "strings" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" + + v1 "github.com/10gen/ops-manager-kubernetes/api/v1" + "github.com/10gen/ops-manager-kubernetes/api/v1/status" + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook" +) + +var _ webhook.Validator = &MongoDB{} + +const UseOfDeprecatedShortcutFieldsWarning = `The use of the spec.podSpec to set cpu, cpuLimits, memory or memoryLimits has been DEPRECATED. +Use spec.podSpec.podTemplate.spec.containers[].resources instead.` + +// ValidateCreate and ValidateUpdate should be the same if we intend to do this +// on every reconciliation as well +func (m *MongoDB) ValidateCreate() error { + return m.ProcessValidationsOnReconcile(nil) +} + +func (m *MongoDB) ValidateUpdate(old runtime.Object) error { + return m.ProcessValidationsOnReconcile(old.(*MongoDB)) +} + +// ValidateDelete does nothing as we assume validation on deletion is +// unnecessary +func (m *MongoDB) ValidateDelete() error { + return nil +} + +func replicaSetHorizonsRequireTLS(d DbCommonSpec) v1.ValidationResult { + if len(d.Connectivity.ReplicaSetHorizons) > 0 && !d.IsSecurityTLSConfigEnabled() { + msg := "TLS must be enabled in order to use replica set horizons" + return v1.ValidationError(msg) + } + return v1.ValidationSuccess() +} + +func horizonsMustEqualMembers(ms MongoDbSpec) v1.ValidationResult { + numHorizonMembers := len(ms.Connectivity.ReplicaSetHorizons) + if numHorizonMembers > 0 && numHorizonMembers != ms.Members { + return v1.ValidationError("Number of horizons must be equal to number of members in replica set") + } + return v1.ValidationSuccess() +} + +func deploymentsMustHaveTLSInX509Env(d DbCommonSpec) v1.ValidationResult { + authSpec := d.Security.Authentication + if authSpec == nil { + return v1.ValidationSuccess() + } + if authSpec.Enabled && authSpec.IsX509Enabled() && !d.GetSecurity().IsTLSEnabled() { + return v1.ValidationError("Cannot have a non-tls deployment when x509 authentication is enabled") + } + return v1.ValidationSuccess() +} + +func deploymentsMustHaveAgentModesIfAuthIsEnabled(d DbCommonSpec) v1.ValidationResult { + authSpec := d.Security.Authentication + if authSpec == nil { + return v1.ValidationSuccess() + } + if authSpec.Enabled && len(authSpec.Modes) == 0 { + return v1.ValidationError("Cannot enable authentication without modes specified") + } + return v1.ValidationSuccess() +} + +func deploymentsMustHaveAgentModeInAuthModes(d DbCommonSpec) v1.ValidationResult { + authSpec := d.Security.Authentication + if authSpec == nil { + return v1.ValidationSuccess() + } + if !authSpec.Enabled { + return v1.ValidationSuccess() + } + + if authSpec.Agents.Mode != "" && !stringutil.Contains(authSpec.Modes, authSpec.Agents.Mode) { + return v1.ValidationError("Cannot configure an Agent authentication mechanism that is not specified in authentication modes") + } + return v1.ValidationSuccess() +} + +// scramSha1AuthValidation performs the same validation as the Ops Manager does in +// https://github.com/10gen/mms/blob/107304ce6988f6280e8af069d19b7c6226c4f3ce/server/src/main/com/xgen/cloud/atm/publish/_public/svc/AutomationValidationSvc.java +func scramSha1AuthValidation(d DbCommonSpec) v1.ValidationResult { + authSpec := d.Security.Authentication + if authSpec == nil { + return v1.ValidationSuccess() + } + if !authSpec.Enabled { + return v1.ValidationSuccess() + } + + if stringutil.Contains(authSpec.Modes, util.SCRAMSHA1) { + if authSpec.Agents.Mode != util.MONGODBCR { + return v1.ValidationError("Cannot configure SCRAM-SHA-1 without using MONGODB-CR in te Agent Mode") + } + } + return v1.ValidationSuccess() +} + +func ldapAuthRequiresEnterprise(d DbCommonSpec) v1.ValidationResult { + authSpec := d.Security.Authentication + if authSpec != nil && authSpec.isLDAPEnabled() && !strings.HasSuffix(d.Version, "-ent") { + return v1.ValidationError("Cannot enable LDAP authentication with MongoDB Community Builds") + } + return v1.ValidationSuccess() +} + +func additionalMongodConfig(ms MongoDbSpec) v1.ValidationResult { + if ms.ResourceType == ShardedCluster { + if ms.AdditionalMongodConfig != nil && ms.AdditionalMongodConfig.object != nil && len(ms.AdditionalMongodConfig.object) > 0 { + return v1.ValidationError("'spec.additionalMongodConfig' cannot be specified if type of MongoDB is %s", ShardedCluster) + } + return v1.ValidationSuccess() + } + // Standalone or ReplicaSet + if ms.ShardSpec != nil || ms.ConfigSrvSpec != nil || ms.MongosSpec != nil { + return v1.ValidationError("'spec.mongos', 'spec.configSrv', 'spec.shard' cannot be specified if type of MongoDB is %s", ms.ResourceType) + } + return v1.ValidationSuccess() +} + +func replicasetMemberIsSpecified(ms MongoDbSpec) v1.ValidationResult { + if ms.ResourceType == ReplicaSet && ms.Members == 0 { + return v1.ValidationError("'spec.members' must be specified if type of MongoDB is %s", ms.ResourceType) + } + return v1.ValidationSuccess() +} + +func agentModeIsSetIfMoreThanADeploymentAuthModeIsSet(d DbCommonSpec) v1.ValidationResult { + if d.Security == nil || d.Security.Authentication == nil { + return v1.ValidationSuccess() + } + if len(d.Security.Authentication.Modes) > 1 && d.Security.Authentication.Agents.Mode == "" { + return v1.ValidationError("spec.security.authentication.agents.mode must be specified if more than one entry is present in spec.security.authentication.modes") + } + return v1.ValidationSuccess() +} + +func ldapGroupDnIsSetIfLdapAuthzIsEnabledAndAgentsAreExternal(d DbCommonSpec) v1.ValidationResult { + if d.Security == nil || d.Security.Authentication == nil || d.Security.Authentication.Ldap == nil { + return v1.ValidationSuccess() + } + auth := d.Security.Authentication + if auth.Ldap.AuthzQueryTemplate != "" && auth.Agents.AutomationLdapGroupDN == "" && stringutil.Contains([]string{"X509", "LDAP"}, auth.Agents.Mode) { + return v1.ValidationError("automationLdapGroupDN must be specified if LDAP authorization is used and agent auth mode is $external (x509 or LDAP)") + } + return v1.ValidationSuccess() +} + +func resourceTypeImmutable(newObj, oldObj MongoDbSpec) v1.ValidationResult { + if newObj.ResourceType != oldObj.ResourceType { + return v1.ValidationError("'resourceType' cannot be changed once created") + } + return v1.ValidationSuccess() +} + +// specWithExactlyOneSchema checks that exactly one among "Project/OpsManagerConfig/CloudManagerConfig" +// is configured, doing the "oneOf" validation in the webhook. +func specWithExactlyOneSchema(d DbCommonSpec) v1.ValidationResult { + count := 0 + if *d.OpsManagerConfig != (PrivateCloudConfig{}) { + count += 1 + } + if *d.CloudManagerConfig != (PrivateCloudConfig{}) { + count += 1 + } + + if count != 1 { + return v1.ValidationError("must validate one and only one schema") + } + return v1.ValidationSuccess() +} + +func CommonValidators() []func(d DbCommonSpec) v1.ValidationResult { + return []func(d DbCommonSpec) v1.ValidationResult{ + replicaSetHorizonsRequireTLS, + deploymentsMustHaveTLSInX509Env, + deploymentsMustHaveAgentModesIfAuthIsEnabled, + deploymentsMustHaveAgentModeInAuthModes, + scramSha1AuthValidation, + ldapAuthRequiresEnterprise, + rolesAttributeisCorrectlyConfigured, + agentModeIsSetIfMoreThanADeploymentAuthModeIsSet, + ldapGroupDnIsSetIfLdapAuthzIsEnabledAndAgentsAreExternal, + specWithExactlyOneSchema, + } +} + +func (m *MongoDB) RunValidations(old *MongoDB) []v1.ValidationResult { + + // apply validators specific to single cluster + singleClusterValidators := []func(m MongoDbSpec) v1.ValidationResult{ + horizonsMustEqualMembers, + additionalMongodConfig, + replicasetMemberIsSpecified, + } + + updateValidators := []func(newObj MongoDbSpec, oldObj MongoDbSpec) v1.ValidationResult{ + resourceTypeImmutable, + } + + var validationResults []v1.ValidationResult + + for _, validator := range singleClusterValidators { + res := validator(m.Spec) + if res.Level > 0 { + validationResults = append(validationResults, res) + } + } + + for _, validator := range CommonValidators() { + res := validator(m.Spec.DbCommonSpec) + if res.Level > 0 { + validationResults = append(validationResults, res) + } + } + + if old == nil { + return validationResults + } + for _, validator := range updateValidators { + res := validator(m.Spec, old.Spec) + if res.Level > 0 { + validationResults = append(validationResults, res) + } + } + return validationResults +} + +func (m *MongoDB) ProcessValidationsOnReconcile(old *MongoDB) error { + for _, res := range m.RunValidations(old) { + if res.Level == v1.ErrorLevel { + return errors.New(res.Msg) + } + + if res.Level == v1.WarningLevel { + m.AddWarningIfNotExists(status.Warning(res.Msg)) + } + } + + return nil +} diff --git a/api/v1/mdb/mongodb_validation_test.go b/api/v1/mdb/mongodb_validation_test.go new file mode 100644 index 000000000..408273810 --- /dev/null +++ b/api/v1/mdb/mongodb_validation_test.go @@ -0,0 +1,151 @@ +package mdb + +import ( + "testing" + + v1 "github.com/10gen/ops-manager-kubernetes/api/v1" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMongoDB_ProcessValidations_BadHorizonsMemberCount(t *testing.T) { + replicaSetHorizons := []MongoDBHorizonConfig{ + {"my-horizon": "my-db.com:12345"}, + {"my-horizon": "my-db.com:12346"}, + } + + rs := NewReplicaSetBuilder().SetSecurityTLSEnabled().Build() + rs.Spec.Connectivity = &MongoDBConnectivity{} + rs.Spec.Connectivity.ReplicaSetHorizons = replicaSetHorizons + err := rs.ProcessValidationsOnReconcile(nil) + assert.Contains(t, "Number of horizons must be equal to number of members in replica set", err.Error()) +} + +func TestMongoDB_ProcessValidations_HorizonsWithoutTLS(t *testing.T) { + replicaSetHorizons := []MongoDBHorizonConfig{ + {"my-horizon": "my-db.com:12345"}, + {"my-horizon": "my-db.com:12342"}, + {"my-horizon": "my-db.com:12346"}, + } + + rs := NewReplicaSetBuilder().Build() + rs.Spec.Connectivity = &MongoDBConnectivity{} + rs.Spec.Connectivity.ReplicaSetHorizons = replicaSetHorizons + err := rs.ProcessValidationsOnReconcile(nil) + assert.Equal(t, "TLS must be enabled in order to use replica set horizons", err.Error()) +} + +func TestMongoDB_ProcessValidationsOnReconcile_X509WithoutTls(t *testing.T) { + rs := NewReplicaSetBuilder().Build() + rs.Spec.Security.Authentication = &Authentication{Enabled: true, Modes: []string{"X509"}} + err := rs.ProcessValidationsOnReconcile(nil) + assert.Equal(t, "Cannot have a non-tls deployment when x509 authentication is enabled", err.Error()) +} + +func TestMongoDB_ValidateCreate_Error(t *testing.T) { + replicaSetHorizons := []MongoDBHorizonConfig{ + {"my-horizon": "my-db.com:12345"}, + {"my-horizon": "my-db.com:12342"}, + {"my-horizon": "my-db.com:12346"}, + } + + rs := NewReplicaSetBuilder().Build() + rs.Spec.Connectivity = &MongoDBConnectivity{} + rs.Spec.Connectivity.ReplicaSetHorizons = replicaSetHorizons + err := rs.ValidateCreate() + assert.Equal(t, "TLS must be enabled in order to use replica set horizons", err.Error()) +} + +func TestMongoDB_MultipleAuthsButNoAgentAuth_Error(t *testing.T) { + rs := NewReplicaSetBuilder().SetVersion("4.0.2-ent").Build() + rs.Spec.Security = &Security{ + TLSConfig: &TLSConfig{Enabled: true}, + Authentication: &Authentication{ + Enabled: true, + Modes: []string{"LDAP", "X509"}, + }, + } + err := rs.ValidateCreate() + assert.Errorf(t, err, "spec.security.authentication.agents.mode must be specified if more than one entry is present in spec.security.authentication.modes") +} + +func TestMongoDB_ResourceTypeImmutable(t *testing.T) { + newRs := NewReplicaSetBuilder().Build() + oldRs := NewReplicaSetBuilder().setType(ShardedCluster).Build() + err := newRs.ValidateUpdate(oldRs) + assert.Errorf(t, err, "'resourceType' cannot be changed once created") +} + +func TestSpecProjectOnlyOneValue(t *testing.T) { + rs := NewReplicaSetBuilder().Build() + rs.Spec.CloudManagerConfig = &PrivateCloudConfig{ + ConfigMapRef: ConfigMapRef{Name: "cloud-manager"}, + } + err := rs.ValidateCreate() + assert.NoError(t, err) +} + +func TestMongoDB_ProcessValidations(t *testing.T) { + rs := NewReplicaSetBuilder().Build() + assert.Error(t, rs.ProcessValidationsOnReconcile(nil), nil) +} + +func TestMongoDB_ValidateAdditionalMongodConfig(t *testing.T) { + t.Run("No sharded cluster additional config for replica set", func(t *testing.T) { + rs := NewReplicaSetBuilder().SetConfigSrvAdditionalConfig(NewAdditionalMongodConfig("systemLog.verbosity", 5)).Build() + err := rs.ValidateCreate() + require.Error(t, err) + assert.Equal(t, "'spec.mongos', 'spec.configSrv', 'spec.shard' cannot be specified if type of MongoDB is ReplicaSet", err.Error()) + }) + t.Run("No sharded cluster additional config for standalone", func(t *testing.T) { + rs := NewStandaloneBuilder().SetMongosAdditionalConfig(NewAdditionalMongodConfig("systemLog.verbosity", 5)).Build() + err := rs.ValidateCreate() + require.Error(t, err) + assert.Equal(t, "'spec.mongos', 'spec.configSrv', 'spec.shard' cannot be specified if type of MongoDB is Standalone", err.Error()) + }) + t.Run("No replica set additional config for sharded cluster", func(t *testing.T) { + rs := NewClusterBuilder().SetAdditionalConfig(NewAdditionalMongodConfig("systemLog.verbosity", 5)).Build() + err := rs.ValidateCreate() + require.Error(t, err) + assert.Equal(t, "'spec.additionalMongodConfig' cannot be specified if type of MongoDB is ShardedCluster", err.Error()) + }) +} + +func TestScramSha1AuthValidation(t *testing.T) { + type TestConfig struct { + MongoDB *MongoDB + ErrorExpected bool + } + tests := map[string]TestConfig{ + "Valid MongoDB with Authentication": { + MongoDB: NewReplicaSetBuilder().EnableAuth([]string{util.SCRAMSHA1}).Build(), + ErrorExpected: true, + }, + "Valid MongoDB with SCRAM-SHA-1": { + MongoDB: NewReplicaSetBuilder().EnableAuth([]string{util.SCRAMSHA1, util.MONGODBCR}).EnableAgentAuth(util.MONGODBCR).Build(), + ErrorExpected: false, + }, + } + for testName, testConfig := range tests { + t.Run(testName, func(t *testing.T) { + validationResult := scramSha1AuthValidation(testConfig.MongoDB.Spec.DbCommonSpec) + assert.Equal(t, testConfig.ErrorExpected, v1.ValidationSuccess() != validationResult, "Expected %v, got %v", testConfig.ErrorExpected, validationResult) + }) + + } +} + +func TestReplicasetMemberIsSpecified(t *testing.T) { + rs := NewDefaultReplicaSetBuilder().Build() + err := rs.ProcessValidationsOnReconcile(nil) + require.Error(t, err) + assert.Errorf(t, err, "'spec.members' must be specified if type of MongoDB is ReplicaSet") + + rs = NewReplicaSetBuilder().Build() + rs.Spec.CloudManagerConfig = &PrivateCloudConfig{ + ConfigMapRef: ConfigMapRef{Name: "cloud-manager"}, + } + require.NoError(t, rs.ProcessValidationsOnReconcile(nil)) +} diff --git a/api/v1/mdb/mongodbbuilder.go b/api/v1/mdb/mongodbbuilder.go new file mode 100644 index 000000000..a3fd63540 --- /dev/null +++ b/api/v1/mdb/mongodbbuilder.go @@ -0,0 +1,218 @@ +package mdb + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TODO must replace all [Standalone|Replicaset|Cluster]Builder classes in 'operator' package +// TODO 2 move this to a separate package 'mongodb' together with 'types.go' and 'podspecbuilder.go' +// Convenience builder for Mongodb object +type MongoDBBuilder struct { + mdb *MongoDB +} + +func NewReplicaSetBuilder() *MongoDBBuilder { + return defaultMongoDB(ReplicaSet).SetMembers(3) +} + +func NewDefaultReplicaSetBuilder() *MongoDBBuilder { + return defaultMongoDB(ReplicaSet) +} +func NewDefaultShardedClusterBuilder() *MongoDBBuilder { + return defaultMongoDB(ShardedCluster) +} + +func NewStandaloneBuilder() *MongoDBBuilder { + return defaultMongoDB(Standalone) +} + +func NewClusterBuilder() *MongoDBBuilder { + sizeConfig := MongodbShardedClusterSizeConfig{ + ShardCount: 2, + MongodsPerShardCount: 3, + ConfigServerCount: 4, + MongosCount: 2, + } + mongodb := defaultMongoDB(ShardedCluster) + mongodb.mdb.Spec.MongodbShardedClusterSizeConfig = sizeConfig + return mongodb +} + +func (b *MongoDBBuilder) SetVersion(version string) *MongoDBBuilder { + b.mdb.Spec.Version = version + return b +} + +func (b *MongoDBBuilder) SetName(name string) *MongoDBBuilder { + b.mdb.Name = name + return b +} + +func (b *MongoDBBuilder) SetNamespace(namespace string) *MongoDBBuilder { + b.mdb.Namespace = namespace + return b +} + +func (b *MongoDBBuilder) SetFCVersion(version string) *MongoDBBuilder { + b.mdb.Spec.FeatureCompatibilityVersion = &version + return b +} + +func (b *MongoDBBuilder) SetMembers(m int) *MongoDBBuilder { + if b.mdb.Spec.ResourceType != ReplicaSet { + panic("Only replicaset can have members configuration") + } + b.mdb.Spec.Members = m + return b +} +func (b *MongoDBBuilder) SetClusterDomain(m string) *MongoDBBuilder { + b.mdb.Spec.ClusterDomain = m + return b +} + +func (b *MongoDBBuilder) SetAdditionalConfig(c *AdditionalMongodConfig) *MongoDBBuilder { + b.mdb.Spec.AdditionalMongodConfig = c + return b +} + +func (b *MongoDBBuilder) SetMongosAdditionalConfig(c *AdditionalMongodConfig) *MongoDBBuilder { + if b.mdb.Spec.MongosSpec == nil { + b.mdb.Spec.MongosSpec = &ShardedClusterComponentSpec{} + } + b.mdb.Spec.MongosSpec.AdditionalMongodConfig = c + return b +} + +func (b *MongoDBBuilder) SetConfigSrvAdditionalConfig(c *AdditionalMongodConfig) *MongoDBBuilder { + if b.mdb.Spec.ConfigSrvSpec == nil { + b.mdb.Spec.ConfigSrvSpec = &ShardedClusterComponentSpec{} + } + b.mdb.Spec.ConfigSrvSpec.AdditionalMongodConfig = c + return b +} + +func (b *MongoDBBuilder) SetShardAdditionalConfig(c *AdditionalMongodConfig) *MongoDBBuilder { + if b.mdb.Spec.ShardSpec == nil { + b.mdb.Spec.ShardSpec = &ShardedClusterComponentSpec{} + } + b.mdb.Spec.ShardSpec.AdditionalMongodConfig = c + return b +} + +func (b *MongoDBBuilder) SetSecurityTLSEnabled() *MongoDBBuilder { + b.mdb.Spec.Security.TLSConfig.Enabled = true + return b +} + +func (b *MongoDBBuilder) SetLabels(labels map[string]string) *MongoDBBuilder { + b.mdb.Labels = labels + return b +} + +func (b *MongoDBBuilder) SetAnnotations(annotations map[string]string) *MongoDBBuilder { + b.mdb.Annotations = annotations + return b +} + +func (b *MongoDBBuilder) EnableAuth(modes []string) *MongoDBBuilder { + if b.mdb.Spec.Security.Authentication == nil { + b.mdb.Spec.Security.Authentication = &Authentication{} + } + b.mdb.Spec.Security.Authentication.Enabled = true + b.mdb.Spec.Security.Authentication.Modes = modes + return b +} + +func (b *MongoDBBuilder) EnableAgentAuth(mode string) *MongoDBBuilder { + if b.mdb.Spec.Security.Authentication == nil { + b.mdb.Spec.Security.Authentication = &Authentication{} + } + b.mdb.Spec.Security.Authentication.Agents.Mode = mode + return b +} + +func (b *MongoDBBuilder) SetShardCountSpec(count int) *MongoDBBuilder { + if b.mdb.Spec.ResourceType != ShardedCluster { + panic("Only sharded cluster can have shards configuration") + } + b.mdb.Spec.ShardCount = count + return b +} +func (b *MongoDBBuilder) SetMongodsPerShardCountSpec(count int) *MongoDBBuilder { + if b.mdb.Spec.ResourceType != ShardedCluster { + panic("Only sharded cluster can have shards configuration") + } + b.mdb.Spec.MongodsPerShardCount = count + return b +} +func (b *MongoDBBuilder) SetConfigServerCountSpec(count int) *MongoDBBuilder { + if b.mdb.Spec.ResourceType != ShardedCluster { + panic("Only sharded cluster can have config server configuration") + } + b.mdb.Spec.ConfigServerCount = count + return b +} +func (b *MongoDBBuilder) SetMongosCountSpec(count int) *MongoDBBuilder { + if b.mdb.Spec.ResourceType != ShardedCluster { + panic("Only sharded cluster can have mongos configuration") + } + b.mdb.Spec.MongosCount = count + return b +} + +func (b *MongoDBBuilder) SetAdditionalOptions(config AdditionalMongodConfig) *MongoDBBuilder { + b.mdb.Spec.AdditionalMongodConfig = &config + return b +} + +func (b *MongoDBBuilder) SetBackup(backupSpec Backup) *MongoDBBuilder { + if b.mdb.Spec.ResourceType == Standalone { + panic("Backup is only supported for ReplicaSets and ShardedClusters") + } + b.mdb.Spec.Backup = &backupSpec + return b +} + +func (b *MongoDBBuilder) SetConnectionSpec(spec ConnectionSpec) *MongoDBBuilder { + b.mdb.Spec.ConnectionSpec = spec + return b +} + +func (b *MongoDBBuilder) SetAgentConfig(agentOptions AgentConfig) *MongoDBBuilder { + b.mdb.Spec.Agent = agentOptions + return b +} + +func (b *MongoDBBuilder) SetPersistent(p *bool) *MongoDBBuilder { + b.mdb.Spec.Persistent = p + return b +} + +func (b *MongoDBBuilder) SetPodSpec(podSpec *MongoDbPodSpec) *MongoDBBuilder { + b.mdb.Spec.PodSpec = podSpec + return b +} + +func (b *MongoDBBuilder) Build() *MongoDB { + b.mdb.InitDefaults() + return b.mdb.DeepCopy() +} + +// ************************* Package private methods ********************************************************* + +func defaultMongoDB(resourceType ResourceType) *MongoDBBuilder { + spec := MongoDbSpec{ + DbCommonSpec: DbCommonSpec{ + Version: "4.0.0", + ResourceType: resourceType, + }, + } + mdb := &MongoDB{Spec: spec, ObjectMeta: metav1.ObjectMeta{Name: "test-mdb", Namespace: "testNS"}} + mdb.InitDefaults() + return &MongoDBBuilder{mdb} +} + +func (b *MongoDBBuilder) setType(resourceType ResourceType) *MongoDBBuilder { + b.mdb.Spec.ResourceType = resourceType + return b +} diff --git a/api/v1/mdb/mongodconfig.go b/api/v1/mdb/mongodconfig.go new file mode 100644 index 000000000..d65ebb0ef --- /dev/null +++ b/api/v1/mdb/mongodconfig.go @@ -0,0 +1,115 @@ +package mdb + +import ( + "encoding/json" + "strings" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/util/maputil" + "go.uber.org/zap" +) + +// The CRD generator does not support map[string]interface{} +// on the top level and hence we need to work around this with +// a wrapping struct. + +// AdditionalMongodConfig contains a private non exported object with a json tag. +// Because we implement the Json marshal and unmarshal interface, json is still able to convert this object into its write-type. +// Making this field private enables us to make sure we don't directly access this field, making sure it is always initialized. +// The space is on purpose to not generate the comment in the CRD. + +type AdditionalMongodConfig struct { + object map[string]interface{} `json:"-"` +} + +// Note: The MarshalJSON and UnmarshalJSON need to be explicitly implemented in this case as our wrapper type itself cannot be marshalled/unmarshalled by default. Without this custom logic the values provided in the resource definition will not be set in the struct created. +// MarshalJSON defers JSON encoding to the wrapped map +func (m *AdditionalMongodConfig) MarshalJSON() ([]byte, error) { + return json.Marshal(m.object) +} + +// UnmarshalJSON will decode the data into the wrapped map +func (m *AdditionalMongodConfig) UnmarshalJSON(data []byte) error { + if m.object == nil { + m.object = map[string]interface{}{} + } + return json.Unmarshal(data, &m.object) +} + +func NewEmptyAdditionalMongodConfig() *AdditionalMongodConfig { + return &AdditionalMongodConfig{object: make(map[string]interface{})} +} + +func NewAdditionalMongodConfig(key string, value interface{}) *AdditionalMongodConfig { + config := NewEmptyAdditionalMongodConfig() + config.AddOption(key, value) + return config +} + +func (c *AdditionalMongodConfig) AddOption(key string, value interface{}) *AdditionalMongodConfig { + keys := strings.Split(key, ".") + maputil.SetMapValue(c.object, value, keys...) + return c +} + +// ToFlatList returns all mongodb options as a sorted list of string values. +// It performs a recursive traversal of maps and dumps the current config to the final list of configs +func (c *AdditionalMongodConfig) ToFlatList() []string { + return maputil.ToFlatList(c.ToMap()) +} + +// GetPortOrDefault returns the port that should be used for the mongo process. +// if no port is specified in the additional mongo args, the default +// port of 27017 will be used +func (c *AdditionalMongodConfig) GetPortOrDefault() int32 { + if c == nil || c.object == nil { + return util.MongoDbDefaultPort + } + + // https://golang.org/pkg/encoding/json/#Unmarshal + // the port will be stored as a float64. + // However, on unit tests, and because of the way the deserialization + // works, this value is returned as an int. That's why we read the + // port as Int which uses the `cast` library to cast both float32 and int + // types into Int. + port := maputil.ReadMapValueAsInt(c.object, "net", "port") + if port == 0 { + return util.MongoDbDefaultPort + } + + return int32(port) +} + +// DeepCopy is defined manually as codegen utility cannot generate copy methods for 'interface{}' +func (in *AdditionalMongodConfig) DeepCopy() *AdditionalMongodConfig { + if in == nil { + return nil + } + out := new(AdditionalMongodConfig) + in.DeepCopyInto(out) + return out +} + +func (in *AdditionalMongodConfig) DeepCopyInto(out *AdditionalMongodConfig) { + cp, err := util.MapDeepCopy(in.object) + if err != nil { + zap.S().Errorf("Failed to copy the map: %s", err) + return + } + config := AdditionalMongodConfig{object: cp} + *out = config +} + +// ToMap creates a copy of the config as a map (Go is quite restrictive to types, and sometimes we need to +// explicitly declare the type as map :( ) +func (c *AdditionalMongodConfig) ToMap() map[string]interface{} { + if c == nil || c.object == nil { + return map[string]interface{}{} + } + cp, err := util.MapDeepCopy(c.object) + if err != nil { + zap.S().Errorf("Failed to copy the map: %s", err) + return nil + } + return cp +} diff --git a/api/v1/mdb/mongodconfig_test.go b/api/v1/mdb/mongodconfig_test.go new file mode 100644 index 000000000..94cac8a93 --- /dev/null +++ b/api/v1/mdb/mongodconfig_test.go @@ -0,0 +1,35 @@ +package mdb + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDeepCopy(t *testing.T) { + config := NewAdditionalMongodConfig("first.second", "value") + cp := *config.DeepCopy() + + expectedAdditionalConfig := AdditionalMongodConfig{ + object: map[string]interface{}{"first": map[string]interface{}{"second": "value"}}} + assert.Equal(t, expectedAdditionalConfig.object, cp.object) + + cp.object["first"].(map[string]interface{})["second"] = "newvalue" + + // The value in the first config hasn't changed + assert.Equal(t, "value", config.object["first"].(map[string]interface{})["second"]) +} + +func TestToFlatList(t *testing.T) { + config := NewAdditionalMongodConfig("one.two.three", "v1") + config.AddOption("one.two.four", 5) + config.AddOption("one.five", true) + config.AddOption("six.seven.eight", "v2") + config.AddOption("six.nine", "v3") + + list := config.ToFlatList() + + expectedStrings := []string{"one.five", "one.two.four", "one.two.three", "six.nine", "six.seven.eight"} + assert.Equal(t, expectedStrings, list) + +} diff --git a/api/v1/mdb/podspecbuilder.go b/api/v1/mdb/podspecbuilder.go new file mode 100644 index 000000000..f604001ac --- /dev/null +++ b/api/v1/mdb/podspecbuilder.go @@ -0,0 +1,155 @@ +package mdb + +import ( + "github.com/10gen/ops-manager-kubernetes/pkg/util" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TODO remove the wrapper in favor of podSpecBuilder +type PodSpecWrapperBuilder struct { + spec PodSpecWrapper +} + +type PersistenceConfigBuilder struct { + config *PersistenceConfig +} + +// NewPodSpecWrapperBuilder returns the builder with some default values, used in tests mostly +func NewPodSpecWrapperBuilder() *PodSpecWrapperBuilder { + spec := MongoDbPodSpec{ + ContainerResourceRequirements: ContainerResourceRequirements{ + CpuLimit: "1.0", + CpuRequests: "0.5", + MemoryLimit: "500M", + MemoryRequests: "400M", + }, + PodTemplateWrapper: PodTemplateSpecWrapper{&corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Affinity: &corev1.Affinity{ + PodAffinity: &corev1.PodAffinity{}, + }, + }, + }}, + } + return &PodSpecWrapperBuilder{PodSpecWrapper{ + MongoDbPodSpec: spec, + Default: NewPodSpecWithDefaultValues(), + }} +} + +func NewPodSpecWrapperBuilderFromSpec(spec *MongoDbPodSpec) *PodSpecWrapperBuilder { + if spec == nil { + return &PodSpecWrapperBuilder{PodSpecWrapper{}} + } + return &PodSpecWrapperBuilder{PodSpecWrapper{MongoDbPodSpec: *spec}} +} + +func NewEmptyPodSpecWrapperBuilder() *PodSpecWrapperBuilder { + return &PodSpecWrapperBuilder{spec: PodSpecWrapper{ + MongoDbPodSpec: MongoDbPodSpec{ + Persistence: &Persistence{}, + }, + Default: MongoDbPodSpec{ + Persistence: &Persistence{SingleConfig: &PersistenceConfig{}}, + }, + }} +} + +func (p *PodSpecWrapperBuilder) SetCpuLimit(cpu string) *PodSpecWrapperBuilder { + p.spec.CpuLimit = cpu + return p +} +func (p *PodSpecWrapperBuilder) SetCpuRequests(cpu string) *PodSpecWrapperBuilder { + p.spec.CpuRequests = cpu + return p +} +func (p *PodSpecWrapperBuilder) SetMemoryLimit(memory string) *PodSpecWrapperBuilder { + p.spec.MemoryLimit = memory + return p +} +func (p *PodSpecWrapperBuilder) SetMemoryRequest(memory string) *PodSpecWrapperBuilder { + p.spec.MemoryRequests = memory + return p +} + +func (p *PodSpecWrapperBuilder) SetPodAffinity(affinity corev1.PodAffinity) *PodSpecWrapperBuilder { + p.spec.PodTemplateWrapper.PodTemplate.Spec.Affinity.PodAffinity = &affinity + return p +} + +func (p *PodSpecWrapperBuilder) SetNodeAffinity(affinity corev1.NodeAffinity) *PodSpecWrapperBuilder { + p.spec.PodTemplateWrapper.PodTemplate.Spec.Affinity.NodeAffinity = &affinity + return p +} + +func (p *PodSpecWrapperBuilder) SetPodAntiAffinityTopologyKey(topologyKey string) *PodSpecWrapperBuilder { + p.spec.PodAntiAffinityTopologyKey = topologyKey + return p +} + +func (p *PodSpecWrapperBuilder) SetPodTemplate(template *corev1.PodTemplateSpec) *PodSpecWrapperBuilder { + p.spec.PodTemplateWrapper.PodTemplate = template + return p +} + +func (p *PodSpecWrapperBuilder) SetSinglePersistence(builder *PersistenceConfigBuilder) *PodSpecWrapperBuilder { + if p.spec.Persistence == nil { + p.spec.Persistence = &Persistence{} + } + p.spec.Persistence.SingleConfig = builder.config + return p +} + +func (p *PodSpecWrapperBuilder) SetMultiplePersistence(dataBuilder, journalBuilder, logsBuilder *PersistenceConfigBuilder) *PodSpecWrapperBuilder { + if p.spec.Persistence == nil { + p.spec.Persistence = &Persistence{} + } + p.spec.Persistence.MultipleConfig = &MultiplePersistenceConfig{} + if dataBuilder != nil { + p.spec.Persistence.MultipleConfig.Data = dataBuilder.config + } + if journalBuilder != nil { + p.spec.Persistence.MultipleConfig.Journal = journalBuilder.config + } + if logsBuilder != nil { + p.spec.Persistence.MultipleConfig.Logs = logsBuilder.config + } + return p +} + +func (p *PodSpecWrapperBuilder) SetDefault(builder *PodSpecWrapperBuilder) *PodSpecWrapperBuilder { + p.spec.Default = builder.Build().MongoDbPodSpec + return p +} + +func (p *PodSpecWrapperBuilder) Build() *PodSpecWrapper { + return p.spec.DeepCopy() +} + +func NewPersistenceBuilder(size string) *PersistenceConfigBuilder { + return &PersistenceConfigBuilder{config: &PersistenceConfig{Storage: size}} +} + +func (p *PersistenceConfigBuilder) SetStorageClass(class string) *PersistenceConfigBuilder { + p.config.StorageClass = &class + return p +} +func (p *PersistenceConfigBuilder) SetLabelSelector(labels map[string]string) *PersistenceConfigBuilder { + p.config.LabelSelector = &LabelSelectorWrapper{metav1.LabelSelector{MatchLabels: labels}} + return p +} + +func NewPodSpecWithDefaultValues() MongoDbPodSpec { + defaultPodSpec := MongoDbPodSpec{PodAntiAffinityTopologyKey: "kubernetes.io/hostname"} + defaultPodSpec.Persistence = &Persistence{ + SingleConfig: &PersistenceConfig{Storage: "30G"}, + MultipleConfig: &MultiplePersistenceConfig{ + Data: &PersistenceConfig{Storage: util.DefaultMongodStorageSize}, + Journal: &PersistenceConfig{Storage: util.DefaultJournalStorageSize}, + Logs: &PersistenceConfig{Storage: util.DefaultLogsStorageSize}, + }, + } + defaultPodSpec.PodTemplateWrapper = NewMongoDbPodSpec().PodTemplateWrapper + return defaultPodSpec +} diff --git a/api/v1/mdb/shardedcluster.go b/api/v1/mdb/shardedcluster.go new file mode 100644 index 000000000..f4b4a382f --- /dev/null +++ b/api/v1/mdb/shardedcluster.go @@ -0,0 +1,53 @@ +package mdb + +// ShardedClusterSpec is the spec consisting of configuration specific for sharded cluster only +type ShardedClusterSpec struct { + // +kubebuilder:pruning:PreserveUnknownFields + ConfigSrvSpec *ShardedClusterComponentSpec `json:"configSrv,omitempty"` + // +kubebuilder:pruning:PreserveUnknownFields + MongosSpec *ShardedClusterComponentSpec `json:"mongos,omitempty"` + // +kubebuilder:pruning:PreserveUnknownFields + ShardSpec *ShardedClusterComponentSpec `json:"shard,omitempty"` + + ConfigSrvPodSpec *MongoDbPodSpec `json:"configSrvPodSpec,omitempty"` + MongosPodSpec *MongoDbPodSpec `json:"mongosPodSpec,omitempty"` + ShardPodSpec *MongoDbPodSpec `json:"shardPodSpec,omitempty"` + // ShardSpecificPodSpec allows you to provide a Statefulset override per shard. + ShardSpecificPodSpec []MongoDbPodSpec `json:"shardSpecificPodSpec,omitempty"` +} + +type ShardedClusterComponentSpec struct { + // +kubebuilder:pruning:PreserveUnknownFields + AdditionalMongodConfig *AdditionalMongodConfig `json:"additionalMongodConfig,omitempty"` + Agent AgentConfig `json:"agent,omitempty"` +} + +func (s *ShardedClusterComponentSpec) GetAdditionalMongodConfig() *AdditionalMongodConfig { + if s == nil { + return &AdditionalMongodConfig{} + } + + if s.AdditionalMongodConfig == nil { + return &AdditionalMongodConfig{} + } + + return s.AdditionalMongodConfig +} + +func (s *ShardedClusterComponentSpec) GetAgentConfig() AgentConfig { + if s == nil { + return AgentConfig{ + StartupParameters: StartupParameters{}, + } + } + return s.Agent +} + +// MongodbShardedClusterSizeConfig describes the numbers and sizes of replica sets inside +// sharded cluster +type MongodbShardedClusterSizeConfig struct { + ShardCount int `json:"shardCount,omitempty"` + MongodsPerShardCount int `json:"mongodsPerShardCount,omitempty"` + MongosCount int `json:"mongosCount,omitempty"` + ConfigServerCount int `json:"configServerCount,omitempty"` +} diff --git a/api/v1/mdb/wrap.go b/api/v1/mdb/wrap.go new file mode 100644 index 000000000..6dfe26af2 --- /dev/null +++ b/api/v1/mdb/wrap.go @@ -0,0 +1,152 @@ +// Contains the wrapped types which are needed for generating +// CRD yamls using kubebuilder. They prevent each of the fields showing up in CRD yaml thereby +// resulting in a relatively smaller file. +package mdb + +import ( + "encoding/json" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type ClientCertificateSecretRefWrapper struct { + ClientCertificateSecretRef corev1.SecretKeySelector `json:"-"` +} + +// MarshalJSON defers JSON encoding to the wrapped map +func (c *ClientCertificateSecretRefWrapper) MarshalJSON() ([]byte, error) { + return json.Marshal(c.ClientCertificateSecretRef) +} + +// UnmarshalJSON will decode the data into the wrapped map +func (c *ClientCertificateSecretRefWrapper) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &c.ClientCertificateSecretRef) +} + +func (c *ClientCertificateSecretRefWrapper) DeepCopy() *ClientCertificateSecretRefWrapper { + return &ClientCertificateSecretRefWrapper{ + ClientCertificateSecretRef: c.ClientCertificateSecretRef, + } +} + +type PodTemplateSpecWrapper struct { + PodTemplate *corev1.PodTemplateSpec `json:"-"` +} + +type LabelSelectorWrapper struct { + LabelSelector metav1.LabelSelector `json:"-"` +} + +// MarshalJSON defers JSON encoding to the wrapped map +func (m *PodTemplateSpecWrapper) MarshalJSON() ([]byte, error) { + return json.Marshal(m.PodTemplate) +} + +// UnmarshalJSON will decode the data into the wrapped map +func (m *PodTemplateSpecWrapper) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &m.PodTemplate) +} + +func (m *PodTemplateSpecWrapper) DeepCopy() *PodTemplateSpecWrapper { + return &PodTemplateSpecWrapper{ + PodTemplate: m.PodTemplate, + } +} + +// MarshalJSON defers JSON encoding to the wrapped map +func (m *LabelSelectorWrapper) MarshalJSON() ([]byte, error) { + return json.Marshal(m.LabelSelector) +} + +// UnmarshalJSON will decode the data into the wrapped map +func (m *LabelSelectorWrapper) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &m.LabelSelector) +} + +func (m *LabelSelectorWrapper) DeepCopy() *LabelSelectorWrapper { + return &LabelSelectorWrapper{ + LabelSelector: m.LabelSelector, + } +} + +type PodAffinityWrapper struct { + PodAffinity *corev1.PodAffinity `json:"-"` +} + +// MarshalJSON defers JSON encoding to the wrapped map +func (m *PodAffinityWrapper) MarshalJSON() ([]byte, error) { + return json.Marshal(m.PodAffinity) +} + +// UnmarshalJSON will decode the data into the wrapped map +func (m *PodAffinityWrapper) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &m.PodAffinity) +} + +func (m *PodAffinityWrapper) DeepCopy() *PodAffinityWrapper { + return &PodAffinityWrapper{ + PodAffinity: m.PodAffinity, + } +} + +type NodeAffinityWrapper struct { + NodeAffinity *corev1.NodeAffinity `json:"-"` +} + +// MarshalJSON defers JSON encoding to the wrapped map +func (m *NodeAffinityWrapper) MarshalJSON() ([]byte, error) { + return json.Marshal(m.NodeAffinity) +} + +// UnmarshalJSON will decode the data into the wrapped map +func (m *NodeAffinityWrapper) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &m.NodeAffinity) +} + +func (m *NodeAffinityWrapper) DeepCopy() *NodeAffinityWrapper { + return &NodeAffinityWrapper{ + NodeAffinity: m.NodeAffinity, + } +} + +type StatefulSetSpecWrapper struct { + Spec appsv1.StatefulSetSpec `json:"-"` +} + +// MarshalJSON defers JSON encoding to the wrapped map +func (s *StatefulSetSpecWrapper) MarshalJSON() ([]byte, error) { + return json.Marshal(s.Spec) +} + +// UnmarshalJSON will decode the data into the wrapped map +func (s *StatefulSetSpecWrapper) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &s.Spec) +} + +func (s *StatefulSetSpecWrapper) DeepCopy() *StatefulSetSpecWrapper { + return &StatefulSetSpecWrapper{ + Spec: s.Spec, + } +} + +type ServiceSpecWrapper struct { + Spec corev1.ServiceSpec `json:"-"` +} + +// MarshalJSON defers JSON encoding to the wrapped map +func (s *ServiceSpecWrapper) MarshalJSON() ([]byte, error) { + return json.Marshal(s.Spec) +} + +// UnmarshalJSON will decode the data into the wrapped map +func (s *ServiceSpecWrapper) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &s.Spec) +} + +func (s *ServiceSpecWrapper) DeepCopy() *ServiceSpecWrapper { + return &ServiceSpecWrapper{ + Spec: s.Spec, + } +} diff --git a/api/v1/mdb/zz_generated.deepcopy.go b/api/v1/mdb/zz_generated.deepcopy.go new file mode 100644 index 000000000..c2d5d1979 --- /dev/null +++ b/api/v1/mdb/zz_generated.deepcopy.go @@ -0,0 +1,1174 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright 2021. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package mdb + +import ( + "github.com/10gen/ops-manager-kubernetes/api/v1/status" + v1 "github.com/mongodb/mongodb-kubernetes-operator/api/v1" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/automationconfig" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentAuthentication) DeepCopyInto(out *AgentAuthentication) { + *out = *in + in.AutomationPasswordSecretRef.DeepCopyInto(&out.AutomationPasswordSecretRef) + in.ClientCertificateSecretRefWrap.DeepCopyInto(&out.ClientCertificateSecretRefWrap) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentAuthentication. +func (in *AgentAuthentication) DeepCopy() *AgentAuthentication { + if in == nil { + return nil + } + out := new(AgentAuthentication) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentConfig) DeepCopyInto(out *AgentConfig) { + *out = *in + if in.StartupParameters != nil { + in, out := &in.StartupParameters, &out.StartupParameters + *out = make(StartupParameters, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentConfig. +func (in *AgentConfig) DeepCopy() *AgentConfig { + if in == nil { + return nil + } + out := new(AgentConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Authentication) DeepCopyInto(out *Authentication) { + *out = *in + if in.Modes != nil { + in, out := &in.Modes, &out.Modes + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Ldap != nil { + in, out := &in.Ldap, &out.Ldap + *out = new(Ldap) + (*in).DeepCopyInto(*out) + } + in.Agents.DeepCopyInto(&out.Agents) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Authentication. +func (in *Authentication) DeepCopy() *Authentication { + if in == nil { + return nil + } + out := new(Authentication) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AuthenticationRestriction) DeepCopyInto(out *AuthenticationRestriction) { + *out = *in + if in.ClientSource != nil { + in, out := &in.ClientSource, &out.ClientSource + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.ServerAddress != nil { + in, out := &in.ServerAddress, &out.ServerAddress + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuthenticationRestriction. +func (in *AuthenticationRestriction) DeepCopy() *AuthenticationRestriction { + if in == nil { + return nil + } + out := new(AuthenticationRestriction) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Backup) DeepCopyInto(out *Backup) { + *out = *in + if in.SnapshotSchedule != nil { + in, out := &in.SnapshotSchedule, &out.SnapshotSchedule + *out = new(SnapshotSchedule) + (*in).DeepCopyInto(*out) + } + if in.Encryption != nil { + in, out := &in.Encryption, &out.Encryption + *out = new(Encryption) + (*in).DeepCopyInto(*out) + } + if in.AssignmentLabels != nil { + in, out := &in.AssignmentLabels, &out.AssignmentLabels + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Backup. +func (in *Backup) DeepCopy() *Backup { + if in == nil { + return nil + } + out := new(Backup) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BackupStatus) DeepCopyInto(out *BackupStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackupStatus. +func (in *BackupStatus) DeepCopy() *BackupStatus { + if in == nil { + return nil + } + out := new(BackupStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClientCertificateSecretRefWrapper) DeepCopyInto(out *ClientCertificateSecretRefWrapper) { + clone := in.DeepCopy() + *out = *clone +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConfigMapRef) DeepCopyInto(out *ConfigMapRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConfigMapRef. +func (in *ConfigMapRef) DeepCopy() *ConfigMapRef { + if in == nil { + return nil + } + out := new(ConfigMapRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConnectionSpec) DeepCopyInto(out *ConnectionSpec) { + *out = *in + in.SharedConnectionSpec.DeepCopyInto(&out.SharedConnectionSpec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConnectionSpec. +func (in *ConnectionSpec) DeepCopy() *ConnectionSpec { + if in == nil { + return nil + } + out := new(ConnectionSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ContainerResourceRequirements) DeepCopyInto(out *ContainerResourceRequirements) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ContainerResourceRequirements. +func (in *ContainerResourceRequirements) DeepCopy() *ContainerResourceRequirements { + if in == nil { + return nil + } + out := new(ContainerResourceRequirements) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Credentials) DeepCopyInto(out *Credentials) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Credentials. +func (in *Credentials) DeepCopy() *Credentials { + if in == nil { + return nil + } + out := new(Credentials) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DbCommonSpec) DeepCopyInto(out *DbCommonSpec) { + *out = *in + if in.FeatureCompatibilityVersion != nil { + in, out := &in.FeatureCompatibilityVersion, &out.FeatureCompatibilityVersion + *out = new(string) + **out = **in + } + in.Agent.DeepCopyInto(&out.Agent) + in.ConnectionSpec.DeepCopyInto(&out.ConnectionSpec) + if in.ExternalAccessConfiguration != nil { + in, out := &in.ExternalAccessConfiguration, &out.ExternalAccessConfiguration + *out = new(ExternalAccessConfiguration) + (*in).DeepCopyInto(*out) + } + if in.Persistent != nil { + in, out := &in.Persistent, &out.Persistent + *out = new(bool) + **out = **in + } + if in.Security != nil { + in, out := &in.Security, &out.Security + *out = new(Security) + (*in).DeepCopyInto(*out) + } + if in.Connectivity != nil { + in, out := &in.Connectivity, &out.Connectivity + *out = new(MongoDBConnectivity) + (*in).DeepCopyInto(*out) + } + if in.Backup != nil { + in, out := &in.Backup, &out.Backup + *out = new(Backup) + (*in).DeepCopyInto(*out) + } + if in.Prometheus != nil { + in, out := &in.Prometheus, &out.Prometheus + *out = new(v1.Prometheus) + **out = **in + } + if in.StatefulSetConfiguration != nil { + in, out := &in.StatefulSetConfiguration, &out.StatefulSetConfiguration + *out = new(v1.StatefulSetConfiguration) + (*in).DeepCopyInto(*out) + } + if in.AdditionalMongodConfig != nil { + in, out := &in.AdditionalMongodConfig, &out.AdditionalMongodConfig + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DbCommonSpec. +func (in *DbCommonSpec) DeepCopy() *DbCommonSpec { + if in == nil { + return nil + } + out := new(DbCommonSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Encryption) DeepCopyInto(out *Encryption) { + *out = *in + if in.Kmip != nil { + in, out := &in.Kmip, &out.Kmip + *out = new(KmipConfig) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Encryption. +func (in *Encryption) DeepCopy() *Encryption { + if in == nil { + return nil + } + out := new(Encryption) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExternalAccessConfiguration) DeepCopyInto(out *ExternalAccessConfiguration) { + *out = *in + in.ExternalService.DeepCopyInto(&out.ExternalService) + if in.ExternalDomain != nil { + in, out := &in.ExternalDomain, &out.ExternalDomain + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalAccessConfiguration. +func (in *ExternalAccessConfiguration) DeepCopy() *ExternalAccessConfiguration { + if in == nil { + return nil + } + out := new(ExternalAccessConfiguration) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExternalServiceConfiguration) DeepCopyInto(out *ExternalServiceConfiguration) { + *out = *in + if in.SpecWrapper != nil { + in, out := &in.SpecWrapper, &out.SpecWrapper + *out = (*in).DeepCopy() + } + if in.Annotations != nil { + in, out := &in.Annotations, &out.Annotations + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalServiceConfiguration. +func (in *ExternalServiceConfiguration) DeepCopy() *ExternalServiceConfiguration { + if in == nil { + return nil + } + out := new(ExternalServiceConfiguration) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InheritedRole) DeepCopyInto(out *InheritedRole) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InheritedRole. +func (in *InheritedRole) DeepCopy() *InheritedRole { + if in == nil { + return nil + } + out := new(InheritedRole) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KmipConfig) DeepCopyInto(out *KmipConfig) { + *out = *in + out.Client = in.Client +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KmipConfig. +func (in *KmipConfig) DeepCopy() *KmipConfig { + if in == nil { + return nil + } + out := new(KmipConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LabelSelectorWrapper) DeepCopyInto(out *LabelSelectorWrapper) { + clone := in.DeepCopy() + *out = *clone +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Ldap) DeepCopyInto(out *Ldap) { + *out = *in + if in.Servers != nil { + in, out := &in.Servers, &out.Servers + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.TransportSecurity != nil { + in, out := &in.TransportSecurity, &out.TransportSecurity + *out = new(TransportSecurity) + **out = **in + } + if in.ValidateLDAPServerConfig != nil { + in, out := &in.ValidateLDAPServerConfig, &out.ValidateLDAPServerConfig + *out = new(bool) + **out = **in + } + if in.CAConfigMapRef != nil { + in, out := &in.CAConfigMapRef, &out.CAConfigMapRef + *out = new(corev1.ConfigMapKeySelector) + (*in).DeepCopyInto(*out) + } + out.BindQuerySecretRef = in.BindQuerySecretRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Ldap. +func (in *Ldap) DeepCopy() *Ldap { + if in == nil { + return nil + } + out := new(Ldap) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MongoDB) DeepCopyInto(out *MongoDB) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Status.DeepCopyInto(&out.Status) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MongoDB. +func (in *MongoDB) DeepCopy() *MongoDB { + if in == nil { + return nil + } + out := new(MongoDB) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MongoDB) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MongoDBBuilder) DeepCopyInto(out *MongoDBBuilder) { + *out = *in + if in.mdb != nil { + in, out := &in.mdb, &out.mdb + *out = new(MongoDB) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MongoDBBuilder. +func (in *MongoDBBuilder) DeepCopy() *MongoDBBuilder { + if in == nil { + return nil + } + out := new(MongoDBBuilder) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MongoDBConnectivity) DeepCopyInto(out *MongoDBConnectivity) { + *out = *in + if in.ReplicaSetHorizons != nil { + in, out := &in.ReplicaSetHorizons, &out.ReplicaSetHorizons + *out = make([]MongoDBHorizonConfig, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = make(MongoDBHorizonConfig, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MongoDBConnectivity. +func (in *MongoDBConnectivity) DeepCopy() *MongoDBConnectivity { + if in == nil { + return nil + } + out := new(MongoDBConnectivity) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in MongoDBHorizonConfig) DeepCopyInto(out *MongoDBHorizonConfig) { + { + in := &in + *out = make(MongoDBHorizonConfig, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MongoDBHorizonConfig. +func (in MongoDBHorizonConfig) DeepCopy() MongoDBHorizonConfig { + if in == nil { + return nil + } + out := new(MongoDBHorizonConfig) + in.DeepCopyInto(out) + return *out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MongoDBList) DeepCopyInto(out *MongoDBList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]MongoDB, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MongoDBList. +func (in *MongoDBList) DeepCopy() *MongoDBList { + if in == nil { + return nil + } + out := new(MongoDBList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MongoDBList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MongoDbPodSpec) DeepCopyInto(out *MongoDbPodSpec) { + *out = *in + out.ContainerResourceRequirements = in.ContainerResourceRequirements + in.PodTemplateWrapper.DeepCopyInto(&out.PodTemplateWrapper) + if in.Persistence != nil { + in, out := &in.Persistence, &out.Persistence + *out = new(Persistence) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MongoDbPodSpec. +func (in *MongoDbPodSpec) DeepCopy() *MongoDbPodSpec { + if in == nil { + return nil + } + out := new(MongoDbPodSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MongoDbRole) DeepCopyInto(out *MongoDbRole) { + *out = *in + if in.AuthenticationRestrictions != nil { + in, out := &in.AuthenticationRestrictions, &out.AuthenticationRestrictions + *out = make([]AuthenticationRestriction, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Privileges != nil { + in, out := &in.Privileges, &out.Privileges + *out = make([]Privilege, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Roles != nil { + in, out := &in.Roles, &out.Roles + *out = make([]InheritedRole, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MongoDbRole. +func (in *MongoDbRole) DeepCopy() *MongoDbRole { + if in == nil { + return nil + } + out := new(MongoDbRole) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MongoDbSpec) DeepCopyInto(out *MongoDbSpec) { + *out = *in + in.DbCommonSpec.DeepCopyInto(&out.DbCommonSpec) + in.ShardedClusterSpec.DeepCopyInto(&out.ShardedClusterSpec) + out.MongodbShardedClusterSizeConfig = in.MongodbShardedClusterSizeConfig + if in.PodSpec != nil { + in, out := &in.PodSpec, &out.PodSpec + *out = new(MongoDbPodSpec) + (*in).DeepCopyInto(*out) + } + if in.MemberConfig != nil { + in, out := &in.MemberConfig, &out.MemberConfig + *out = make([]automationconfig.MemberOptions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MongoDbSpec. +func (in *MongoDbSpec) DeepCopy() *MongoDbSpec { + if in == nil { + return nil + } + out := new(MongoDbSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MongoDbStatus) DeepCopyInto(out *MongoDbStatus) { + *out = *in + in.Common.DeepCopyInto(&out.Common) + if in.BackupStatus != nil { + in, out := &in.BackupStatus, &out.BackupStatus + *out = new(BackupStatus) + **out = **in + } + out.MongodbShardedClusterSizeConfig = in.MongodbShardedClusterSizeConfig + if in.Warnings != nil { + in, out := &in.Warnings, &out.Warnings + *out = make([]status.Warning, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MongoDbStatus. +func (in *MongoDbStatus) DeepCopy() *MongoDbStatus { + if in == nil { + return nil + } + out := new(MongoDbStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MongodbShardedClusterSizeConfig) DeepCopyInto(out *MongodbShardedClusterSizeConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MongodbShardedClusterSizeConfig. +func (in *MongodbShardedClusterSizeConfig) DeepCopy() *MongodbShardedClusterSizeConfig { + if in == nil { + return nil + } + out := new(MongodbShardedClusterSizeConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MultiplePersistenceConfig) DeepCopyInto(out *MultiplePersistenceConfig) { + *out = *in + if in.Data != nil { + in, out := &in.Data, &out.Data + *out = new(PersistenceConfig) + (*in).DeepCopyInto(*out) + } + if in.Journal != nil { + in, out := &in.Journal, &out.Journal + *out = new(PersistenceConfig) + (*in).DeepCopyInto(*out) + } + if in.Logs != nil { + in, out := &in.Logs, &out.Logs + *out = new(PersistenceConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MultiplePersistenceConfig. +func (in *MultiplePersistenceConfig) DeepCopy() *MultiplePersistenceConfig { + if in == nil { + return nil + } + out := new(MultiplePersistenceConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NodeAffinityWrapper) DeepCopyInto(out *NodeAffinityWrapper) { + clone := in.DeepCopy() + *out = *clone +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Persistence) DeepCopyInto(out *Persistence) { + *out = *in + if in.SingleConfig != nil { + in, out := &in.SingleConfig, &out.SingleConfig + *out = new(PersistenceConfig) + (*in).DeepCopyInto(*out) + } + if in.MultipleConfig != nil { + in, out := &in.MultipleConfig, &out.MultipleConfig + *out = new(MultiplePersistenceConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Persistence. +func (in *Persistence) DeepCopy() *Persistence { + if in == nil { + return nil + } + out := new(Persistence) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PersistenceConfig) DeepCopyInto(out *PersistenceConfig) { + *out = *in + if in.StorageClass != nil { + in, out := &in.StorageClass, &out.StorageClass + *out = new(string) + **out = **in + } + if in.LabelSelector != nil { + in, out := &in.LabelSelector, &out.LabelSelector + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PersistenceConfig. +func (in *PersistenceConfig) DeepCopy() *PersistenceConfig { + if in == nil { + return nil + } + out := new(PersistenceConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PersistenceConfigBuilder) DeepCopyInto(out *PersistenceConfigBuilder) { + *out = *in + if in.config != nil { + in, out := &in.config, &out.config + *out = new(PersistenceConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PersistenceConfigBuilder. +func (in *PersistenceConfigBuilder) DeepCopy() *PersistenceConfigBuilder { + if in == nil { + return nil + } + out := new(PersistenceConfigBuilder) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PodAffinityWrapper) DeepCopyInto(out *PodAffinityWrapper) { + clone := in.DeepCopy() + *out = *clone +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PodSpecWrapper) DeepCopyInto(out *PodSpecWrapper) { + *out = *in + in.MongoDbPodSpec.DeepCopyInto(&out.MongoDbPodSpec) + in.Default.DeepCopyInto(&out.Default) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PodSpecWrapper. +func (in *PodSpecWrapper) DeepCopy() *PodSpecWrapper { + if in == nil { + return nil + } + out := new(PodSpecWrapper) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PodSpecWrapperBuilder) DeepCopyInto(out *PodSpecWrapperBuilder) { + *out = *in + in.spec.DeepCopyInto(&out.spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PodSpecWrapperBuilder. +func (in *PodSpecWrapperBuilder) DeepCopy() *PodSpecWrapperBuilder { + if in == nil { + return nil + } + out := new(PodSpecWrapperBuilder) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PodTemplateSpecWrapper) DeepCopyInto(out *PodTemplateSpecWrapper) { + clone := in.DeepCopy() + *out = *clone +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PrivateCloudConfig) DeepCopyInto(out *PrivateCloudConfig) { + *out = *in + out.ConfigMapRef = in.ConfigMapRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PrivateCloudConfig. +func (in *PrivateCloudConfig) DeepCopy() *PrivateCloudConfig { + if in == nil { + return nil + } + out := new(PrivateCloudConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Privilege) DeepCopyInto(out *Privilege) { + *out = *in + if in.Actions != nil { + in, out := &in.Actions, &out.Actions + *out = make([]string, len(*in)) + copy(*out, *in) + } + in.Resource.DeepCopyInto(&out.Resource) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Privilege. +func (in *Privilege) DeepCopy() *Privilege { + if in == nil { + return nil + } + out := new(Privilege) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProjectConfig) DeepCopyInto(out *ProjectConfig) { + *out = *in + out.SSLProjectConfig = in.SSLProjectConfig +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectConfig. +func (in *ProjectConfig) DeepCopy() *ProjectConfig { + if in == nil { + return nil + } + out := new(ProjectConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Resource) DeepCopyInto(out *Resource) { + *out = *in + if in.Cluster != nil { + in, out := &in.Cluster, &out.Cluster + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Resource. +func (in *Resource) DeepCopy() *Resource { + if in == nil { + return nil + } + out := new(Resource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecretRef) DeepCopyInto(out *SecretRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretRef. +func (in *SecretRef) DeepCopy() *SecretRef { + if in == nil { + return nil + } + out := new(SecretRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Security) DeepCopyInto(out *Security) { + *out = *in + if in.TLSConfig != nil { + in, out := &in.TLSConfig, &out.TLSConfig + *out = new(TLSConfig) + (*in).DeepCopyInto(*out) + } + if in.Authentication != nil { + in, out := &in.Authentication, &out.Authentication + *out = new(Authentication) + (*in).DeepCopyInto(*out) + } + if in.Roles != nil { + in, out := &in.Roles, &out.Roles + *out = make([]MongoDbRole, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Security. +func (in *Security) DeepCopy() *Security { + if in == nil { + return nil + } + out := new(Security) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceSpecWrapper) DeepCopyInto(out *ServiceSpecWrapper) { + clone := in.DeepCopy() + *out = *clone +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ShardedClusterComponentSpec) DeepCopyInto(out *ShardedClusterComponentSpec) { + *out = *in + if in.AdditionalMongodConfig != nil { + in, out := &in.AdditionalMongodConfig, &out.AdditionalMongodConfig + *out = (*in).DeepCopy() + } + in.Agent.DeepCopyInto(&out.Agent) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ShardedClusterComponentSpec. +func (in *ShardedClusterComponentSpec) DeepCopy() *ShardedClusterComponentSpec { + if in == nil { + return nil + } + out := new(ShardedClusterComponentSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ShardedClusterSpec) DeepCopyInto(out *ShardedClusterSpec) { + *out = *in + if in.ConfigSrvSpec != nil { + in, out := &in.ConfigSrvSpec, &out.ConfigSrvSpec + *out = new(ShardedClusterComponentSpec) + (*in).DeepCopyInto(*out) + } + if in.MongosSpec != nil { + in, out := &in.MongosSpec, &out.MongosSpec + *out = new(ShardedClusterComponentSpec) + (*in).DeepCopyInto(*out) + } + if in.ShardSpec != nil { + in, out := &in.ShardSpec, &out.ShardSpec + *out = new(ShardedClusterComponentSpec) + (*in).DeepCopyInto(*out) + } + if in.ConfigSrvPodSpec != nil { + in, out := &in.ConfigSrvPodSpec, &out.ConfigSrvPodSpec + *out = new(MongoDbPodSpec) + (*in).DeepCopyInto(*out) + } + if in.MongosPodSpec != nil { + in, out := &in.MongosPodSpec, &out.MongosPodSpec + *out = new(MongoDbPodSpec) + (*in).DeepCopyInto(*out) + } + if in.ShardPodSpec != nil { + in, out := &in.ShardPodSpec, &out.ShardPodSpec + *out = new(MongoDbPodSpec) + (*in).DeepCopyInto(*out) + } + if in.ShardSpecificPodSpec != nil { + in, out := &in.ShardSpecificPodSpec, &out.ShardSpecificPodSpec + *out = make([]MongoDbPodSpec, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ShardedClusterSpec. +func (in *ShardedClusterSpec) DeepCopy() *ShardedClusterSpec { + if in == nil { + return nil + } + out := new(ShardedClusterSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SharedConnectionSpec) DeepCopyInto(out *SharedConnectionSpec) { + *out = *in + if in.OpsManagerConfig != nil { + in, out := &in.OpsManagerConfig, &out.OpsManagerConfig + *out = new(PrivateCloudConfig) + **out = **in + } + if in.CloudManagerConfig != nil { + in, out := &in.CloudManagerConfig, &out.CloudManagerConfig + *out = new(PrivateCloudConfig) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SharedConnectionSpec. +func (in *SharedConnectionSpec) DeepCopy() *SharedConnectionSpec { + if in == nil { + return nil + } + out := new(SharedConnectionSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SnapshotSchedule) DeepCopyInto(out *SnapshotSchedule) { + *out = *in + if in.SnapshotIntervalHours != nil { + in, out := &in.SnapshotIntervalHours, &out.SnapshotIntervalHours + *out = new(int) + **out = **in + } + if in.SnapshotRetentionDays != nil { + in, out := &in.SnapshotRetentionDays, &out.SnapshotRetentionDays + *out = new(int) + **out = **in + } + if in.DailySnapshotRetentionDays != nil { + in, out := &in.DailySnapshotRetentionDays, &out.DailySnapshotRetentionDays + *out = new(int) + **out = **in + } + if in.WeeklySnapshotRetentionWeeks != nil { + in, out := &in.WeeklySnapshotRetentionWeeks, &out.WeeklySnapshotRetentionWeeks + *out = new(int) + **out = **in + } + if in.MonthlySnapshotRetentionMonths != nil { + in, out := &in.MonthlySnapshotRetentionMonths, &out.MonthlySnapshotRetentionMonths + *out = new(int) + **out = **in + } + if in.PointInTimeWindowHours != nil { + in, out := &in.PointInTimeWindowHours, &out.PointInTimeWindowHours + *out = new(int) + **out = **in + } + if in.ReferenceHourOfDay != nil { + in, out := &in.ReferenceHourOfDay, &out.ReferenceHourOfDay + *out = new(int) + **out = **in + } + if in.ReferenceMinuteOfHour != nil { + in, out := &in.ReferenceMinuteOfHour, &out.ReferenceMinuteOfHour + *out = new(int) + **out = **in + } + if in.FullIncrementalDayOfWeek != nil { + in, out := &in.FullIncrementalDayOfWeek, &out.FullIncrementalDayOfWeek + *out = new(string) + **out = **in + } + if in.ClusterCheckpointIntervalMin != nil { + in, out := &in.ClusterCheckpointIntervalMin, &out.ClusterCheckpointIntervalMin + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SnapshotSchedule. +func (in *SnapshotSchedule) DeepCopy() *SnapshotSchedule { + if in == nil { + return nil + } + out := new(SnapshotSchedule) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in StartupParameters) DeepCopyInto(out *StartupParameters) { + { + in := &in + *out = make(StartupParameters, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StartupParameters. +func (in StartupParameters) DeepCopy() StartupParameters { + if in == nil { + return nil + } + out := new(StartupParameters) + in.DeepCopyInto(out) + return *out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StatefulSetSpecWrapper) DeepCopyInto(out *StatefulSetSpecWrapper) { + clone := in.DeepCopy() + *out = *clone +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TLSConfig) DeepCopyInto(out *TLSConfig) { + *out = *in + if in.AdditionalCertificateDomains != nil { + in, out := &in.AdditionalCertificateDomains, &out.AdditionalCertificateDomains + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSConfig. +func (in *TLSConfig) DeepCopy() *TLSConfig { + if in == nil { + return nil + } + out := new(TLSConfig) + in.DeepCopyInto(out) + return out +} diff --git a/api/v1/mdbmulti/doc.go b/api/v1/mdbmulti/doc.go new file mode 100644 index 000000000..d6a165e6c --- /dev/null +++ b/api/v1/mdbmulti/doc.go @@ -0,0 +1,4 @@ +package mdbmulti + +// +k8s:deepcopy-gen=package +// +versionName=v1 diff --git a/api/v1/mdbmulti/groupversion_info.go b/api/v1/mdbmulti/groupversion_info.go new file mode 100644 index 000000000..00ca5c87e --- /dev/null +++ b/api/v1/mdbmulti/groupversion_info.go @@ -0,0 +1,20 @@ +// Package v1 contains API Schema definitions for the mongodb v1 API group +// +kubebuilder:object:generate=true +// +groupName=mongodb.com +package mdbmulti + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects + GroupVersion = schema.GroupVersion{Group: "mongodb.com", Version: "v1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/api/v1/mdbmulti/mongodb_multi_types.go b/api/v1/mdbmulti/mongodb_multi_types.go new file mode 100644 index 000000000..385d5912d --- /dev/null +++ b/api/v1/mdbmulti/mongodb_multi_types.go @@ -0,0 +1,655 @@ +package mdbmulti + +import ( + "encoding/json" + "fmt" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/connectionstring" + "github.com/10gen/ops-manager-kubernetes/pkg/dns" + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "github.com/10gen/ops-manager-kubernetes/pkg/multicluster/failedcluster" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + intp "github.com/10gen/ops-manager-kubernetes/pkg/util/int" + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/cluster" + "sigs.k8s.io/controller-runtime/pkg/manager" + + "strings" + + v1 "github.com/10gen/ops-manager-kubernetes/api/v1" + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/api/v1/status" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/ldap" + "github.com/blang/semver" + mdbc "github.com/mongodb/mongodb-kubernetes-operator/api/v1" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/automationconfig" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func init() { + v1.SchemeBuilder.Register(&MongoDBMultiCluster{}, &MongoDBMultiClusterList{}) +} + +type TransportSecurity string + +const ( + LastClusterNumMapping = "mongodb.com/v1.lastClusterNumMapping" + TransportSecurityNone TransportSecurity = "none" + TransportSecurityTLS TransportSecurity = "tls" +) + +// The MongoDBMultiCluster resource allows users to create MongoDB deployment spread over +// multiple clusters + +// +kubebuilder:object:root=true +// +k8s:openapi-gen=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:path= mongodbmulticluster,scope=Namespaced,shortName=mdbmc +// +kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase",description="Current state of the MongoDB deployment." +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="The time since the MongoDBMultiCluster resource was created." +type MongoDBMultiCluster struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + // +optional + Status MongoDBMultiStatus `json:"status"` + Spec MongoDBMultiSpec `json:"spec"` +} + +func (m *MongoDBMultiCluster) AddValidationToManager(mgr manager.Manager, clt map[string]cluster.Cluster) error { + return ctrl.NewWebhookManagedBy(mgr).For(m).Complete() +} + +func (m MongoDBMultiCluster) GetProjectConfigMapNamespace() string { + return m.Namespace +} + +func (m MongoDBMultiCluster) GetCredentialsSecretNamespace() string { + return m.Namespace +} + +func (m MongoDBMultiCluster) GetProjectConfigMapName() string { + return m.Spec.OpsManagerConfig.ConfigMapRef.Name +} + +func (m MongoDBMultiCluster) GetCredentialsSecretName() string { + return m.Spec.Credentials +} + +func (m MongoDBMultiCluster) GetMultiClusterAgentHostnames() ([]string, error) { + hostnames := make([]string, 0) + + clusterSpecList, err := m.GetClusterSpecItems() + if err != nil { + return nil, err + } + + for _, spec := range clusterSpecList { + hostnames = append(hostnames, dns.GetMultiClusterAgentHostnames(m.Name, m.Namespace, m.ClusterNum(spec.ClusterName), spec.Members, nil)...) + } + return hostnames, nil +} + +func (m MongoDBMultiCluster) MultiStatefulsetName(clusterNum int) string { + return fmt.Sprintf("%s-%d", m.Name, clusterNum) +} + +func (m MongoDBMultiCluster) ExternalMemberClusterDomain(clusterName string) *string { + for _, csl := range m.Spec.ClusterSpecList { + if csl.ClusterName == clusterName { + return csl.ExternalAccessConfiguration.ExternalDomain + } + } + return nil +} + +func (m MongoDBMultiCluster) GetBackupSpec() *mdbv1.Backup { + return m.Spec.Backup +} + +func (m MongoDBMultiCluster) GetResourceType() mdbv1.ResourceType { + return m.Spec.ResourceType +} + +func (m MongoDBMultiCluster) GetResourceName() string { + return m.Name +} + +func (m *MongoDBMultiCluster) GetSecurity() *mdbv1.Security { + return m.Spec.Security +} + +func (m *MongoDBMultiCluster) GetConnectionSpec() *mdbv1.ConnectionSpec { + return &m.Spec.ConnectionSpec +} + +func (m *MongoDBMultiCluster) GetPrometheus() *mdbc.Prometheus { + return m.Spec.Prometheus +} + +func (m *MongoDBMultiCluster) GetMinimumMajorVersion() uint64 { + return m.Spec.MinimumMajorVersion() +} + +func (m *MongoDBMultiCluster) IsLDAPEnabled() bool { + if m.Spec.Security == nil || m.Spec.Security.Authentication == nil { + return false + } + return stringutil.Contains(m.Spec.GetSecurityAuthenticationModes(), util.LDAP) +} + +func (m *MongoDBMultiCluster) GetLDAP(password, caContents string) *ldap.Ldap { + if !m.IsLDAPEnabled() { + return nil + } + mdbLdap := m.Spec.Security.Authentication.Ldap + transportSecurity := mdbv1.GetTransportSecurity(mdbLdap) + + validateServerConfig := true + if mdbLdap.ValidateLDAPServerConfig != nil { + validateServerConfig = *mdbLdap.ValidateLDAPServerConfig + } + + return &ldap.Ldap{ + BindQueryUser: mdbLdap.BindQueryUser, + BindQueryPassword: password, + Servers: strings.Join(mdbLdap.Servers, ","), + TransportSecurity: string(transportSecurity), + CaFileContents: caContents, + ValidateLDAPServerConfig: validateServerConfig, + + // Related to LDAP Authorization + AuthzQueryTemplate: mdbLdap.AuthzQueryTemplate, + UserToDnMapping: mdbLdap.UserToDNMapping, + + // TODO: Enable LDAP SASL bind method + BindMethod: "simple", + BindSaslMechanisms: "", + } +} + +func (m MongoDBMultiCluster) GetHostNameOverrideConfigmapName() string { + return fmt.Sprintf("%s-hostname-override", m.Name) +} + +func (m MongoDBMultiCluster) ObjectKey() client.ObjectKey { + return kube.ObjectKey(m.Namespace, m.Name) +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type MongoDBMultiClusterList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + Items []MongoDBMultiCluster `json:"items"` +} + +func (m MongoDBMultiCluster) GetClusterSpecByName(clusterName string) *ClusterSpecItem { + for _, csi := range m.Spec.ClusterSpecList { + if csi.ClusterName == clusterName { + return &csi + } + } + return nil +} + +// ClusterSpecItem is the mongodb multi-cluster spec that is specific to a +// particular Kubernetes cluster, this maps to the statefulset created in each cluster +type ClusterSpecItem struct { + // ClusterName is name of the cluster where the MongoDB Statefulset will be scheduled, the + // name should have a one on one mapping with the service-account created in the central cluster + // to talk to the workload clusters. + ClusterName string `json:"clusterName,omitempty"` + // this is an optional service, it will get the name "-service" in case not provided + Service string `json:"service,omitempty"` + // DEPRECATED: use ExternalAccessConfiguration instead + // +optional + ExposedExternally *bool `json:"exposedExternally,omitempty"` + // ExternalAccessConfiguration provides external access configuration for Multi-Cluster. + // +optional + ExternalAccessConfiguration mdbv1.ExternalAccessConfiguration `json:"externalAccess,omitempty"` + // Amount of members for this MongoDB Replica Set + Members int `json:"members"` + // MemberConfig + // +kubebuilder:pruning:PreserveUnknownFields + // +optional + MemberConfig []automationconfig.MemberOptions `json:"memberConfig,omitempty"` + // +optional + StatefulSetConfiguration *mdbc.StatefulSetConfiguration `json:"statefulSet,omitempty"` + // Discard holds the value(true or false) whether a cluster should be removed while generating the clusterEntries + // for a reconcilliation + Discard bool `json:"-"` +} + +// ClusterStatusList holds a list of clusterStatuses corresponding to each cluster +type ClusterStatusList struct { + ClusterStatuses []ClusterStatusItem `json:"clusterStatuses,omitempty"` +} + +// ClusterStatusItem is the mongodb multi-cluster spec that is specific to a +// particular Kubernetes cluster, this maps to the statefulset created in each cluster +type ClusterStatusItem struct { + // ClusterName is name of the cluster where the MongoDB Statefulset will be scheduled, the + // name should have a one on one mapping with the service-account created in the central cluster + // to talk to the workload clusters. + ClusterName string `json:"clusterName,omitempty"` + status.Common `json:",inline"` + Members int `json:"members,omitempty"` + Warnings []status.Warning `json:"warnings,omitempty"` +} + +type MongoDBMultiStatus struct { + status.Common `json:",inline"` + ClusterStatusList ClusterStatusList `json:"clusterStatusList,omitempty"` + BackupStatus *mdbv1.BackupStatus `json:"backup,omitempty"` + Version string `json:"version"` + Link string `json:"link,omitempty"` + Warnings []status.Warning `json:"warnings,omitempty"` +} + +type MongoDBMultiSpec struct { + // +kubebuilder:pruning:PreserveUnknownFields + mdbv1.DbCommonSpec `json:",inline"` + + // In few service mesh options for ex: Istio, by default we would need to duplicate the + // service objects created per pod in all the clusters to enable DNS resolution. Users can + // however configure their ServiceMesh with DNS proxy(https://istio.io/latest/docs/ops/configuration/traffic-management/dns-proxy/) + // enabled in which case the operator doesn't need to create the service objects per cluster. This options tells the operator + // whether it should create the service objects in all the clusters or not. By default, if not specified the operator would create the duplicate svc objects. + DuplicateServiceObjects *bool `json:"duplicateServiceObjects,omitempty"` + ClusterSpecList []ClusterSpecItem `json:"clusterSpecList,omitempty"` + + // Mapping stores the deterministic index for a given cluster-name. + Mapping map[string]int `json:"-"` +} + +func (m MongoDBMultiCluster) GetPlural() string { + return "mongodbmulticluster" +} + +func (m *MongoDBMultiCluster) GetStatus(...status.Option) interface{} { + return m.Status +} + +func (m *MongoDBMultiCluster) GetStatusPath(...status.Option) string { + return "/status" +} + +func (m *MongoDBMultiCluster) SetWarnings(warnings []status.Warning, _ ...status.Option) { + m.Status.Warnings = warnings +} + +func (m *MongoDBMultiCluster) UpdateStatus(phase status.Phase, statusOptions ...status.Option) { + m.Status.UpdateCommonFields(phase, m.GetGeneration(), statusOptions...) + + if option, exists := status.GetOption(statusOptions, status.BackupStatusOption{}); exists { + if m.Status.BackupStatus == nil { + m.Status.BackupStatus = &mdbv1.BackupStatus{} + } + m.Status.BackupStatus.StatusName = option.(status.BackupStatusOption).Value().(string) + } +} + +// GetClusterSpecItems returns the cluster spec items that should be used for reconciliation. +// These may not be the values specified in the spec directly, this takes into account the following three conditions: +// 1. Adding/Removing cluster from the clusterSpecList. +// 2. Scaling the number of nodes of each cluster. +// 3. When there is a cluster outage, there is an annotation put in the CR to orchestrate workloads out +// of the impacted cluster to the remaining clusters. +// The return value should be used in the reconciliation loop when determining which processes +// should be added to the automation config and which services need to be created and how many replicas +// each StatefulSet should have. +// This function should always be used instead of accessing the struct fields directly in the Reconcile function. +func (m *MongoDBMultiCluster) GetClusterSpecItems() ([]ClusterSpecItem, error) { + clusterSpecs := m.GetDesiredSpecList() + prevSpec, err := m.ReadLastAchievedSpec() + if err != nil { + return nil, err + } + + if prevSpec == nil { + return clusterSpecs, nil + } + + prevSpecs := prevSpec.GetClusterSpecList() + + var specsForThisReconciliation []ClusterSpecItem + specsForThisReconciliation = append(specsForThisReconciliation, prevSpecs...) + + // When we remove a cluster, this means that there will be an entry in the resource annotation (the previous spec) + // but not in the current spec. In order to make scaling work, we add an entry for the removed cluster that has + // 0 members. This allows the following scaling down logic to handle the transition from n -> 0 members, with a + // decrementing value of one with each reconciliation. After this, we delete the StatefulSet if the spec item + // was removed. + + // E.g. + // Reconciliation 1: + // 3 clusters all with 3 members + // Reconciliation 2: + // 2 clusters with 3 members (we removed the last cluster. + // The spec has 2 members, but we add a third with 0 members. + // This "dummy" item will be handled the same as another spec item. + // This is only relevant for the first reconciliation after removal since this cluster spec will be saved + // in an annotation, and the regular scaling logic will happen in subsequent reconciliations. + // We go from members 3-3-3 to 3-3-2 + // Reconciliation 3: + // We go from 3-3-2 to 3-3-1 + // Reconciliation 4: + // We go from 3-3-1 to 3-3-0 (and then delete the StatefulSet in this final reconciliation) + + clusterSpecsMap := clusterSpecItemListToMap(clusterSpecs) + for _, previousItem := range prevSpecs { + if _, ok := clusterSpecsMap[previousItem.ClusterName]; !ok { + previousItem.Members = 0 + clusterSpecs = append(clusterSpecs, previousItem) + } + } + + prevSpecsMap := clusterSpecItemListToMap(prevSpecs) + for _, item := range clusterSpecs { + // if a spec item exists but was not there previously, we add it with a single member. + // this allows subsequent reconciliations to go from 1-> n one member at a time as usual. + // it will never be possible to add a new member at the maximum members since scaling can only ever be done + // one at a time. Adding the item with 1 member allows the regular logic to handle scaling one a time until + // we reach the desired member count. + prevItem, ok := prevSpecsMap[item.ClusterName] + if !ok { + if item.Members > 1 { + item.Members = 1 + } + return append(specsForThisReconciliation, item), nil + } + // can only scale one member at a time so we return early on each increment. + if item.Members > prevItem.Members { + specsForThisReconciliation[m.ClusterNum(item.ClusterName)].Members += 1 + return specsForThisReconciliation, nil + } + if item.Members < prevItem.Members { + specsForThisReconciliation[m.ClusterNum(item.ClusterName)].Members -= 1 + return specsForThisReconciliation, nil + } + } + + return specsForThisReconciliation, nil +} + +// HasClustersToFailOver checks if the MongoDBMultiCluster CR has ""clusterSpecOverride" annotation which is put when one or more clusters +// are not reachable. +func HasClustersToFailOver(annotations map[string]string) (string, bool) { + if annotations == nil { + return "", false + } + val, ok := annotations[failedcluster.ClusterSpecOverrideAnnotation] + return val, ok +} + +// GetFailedClusters returns the current set of failed clusters for the MongoDBMultiCluster CR. +func (m *MongoDBMultiCluster) GetFailedClusters() ([]failedcluster.FailedCluster, error) { + if m.Annotations == nil { + return nil, nil + } + failedClusterBytes, ok := m.Annotations[failedcluster.FailedClusterAnnotation] + if !ok { + return []failedcluster.FailedCluster{}, nil + } + var failedClusters []failedcluster.FailedCluster + err := json.Unmarshal([]byte(failedClusterBytes), &failedClusters) + if err != nil { + return nil, err + } + return failedClusters, err +} + +// GetFailedClusterNames returns the current set of failed cluster names for the MongoDBMultiCluster CR. +func (m *MongoDBMultiCluster) GetFailedClusterNames() ([]string, error) { + failedClusters, err := m.GetFailedClusters() + if err != nil { + return nil, err + } + clusterNames := []string{} + for _, c := range failedClusters { + clusterNames = append(clusterNames, c.ClusterName) + } + return clusterNames, nil +} + +// clusterSpecItemListToMap converts a slice of cluster spec items into a map using the name as the key. +func clusterSpecItemListToMap(clusterSpecItems []ClusterSpecItem) map[string]ClusterSpecItem { + m := map[string]ClusterSpecItem{} + for _, c := range clusterSpecItems { + m[c.ClusterName] = c + } + return m +} + +// ReadLastAchievedSpec fetches the previously achieved spec. +func (m *MongoDBMultiCluster) ReadLastAchievedSpec() (*MongoDBMultiSpec, error) { + if m.Annotations == nil { + return nil, nil + } + specBytes, ok := m.Annotations[util.LastAchievedSpec] + if !ok { + return nil, nil + } + + prevSpec := &MongoDBMultiSpec{} + if err := json.Unmarshal([]byte(specBytes), &prevSpec); err != nil { + return nil, err + } + return prevSpec, nil +} + +func (m *MongoDBMultiCluster) GetLastAdditionalMongodConfig() map[string]interface{} { + lastSpec, err := m.ReadLastAchievedSpec() + if lastSpec == nil || err != nil { + return map[string]interface{}{} + } + return lastSpec.GetAdditionalMongodConfig().ToMap() +} + +// when unmarshalling a MongoDBMultiCluster instance, we don't want to have any nil references +// these are replaced with an empty instance to prevent nil references +func (m *MongoDBMultiCluster) UnmarshalJSON(data []byte) error { + type MongoDBJSON *MongoDBMultiCluster + if err := json.Unmarshal(data, (MongoDBJSON)(m)); err != nil { + return err + } + + m.InitDefaults() + return nil +} + +// InitDefaults makes sure the MongoDBMultiCluster resource has correct state after initialization: +// - prevents any references from having nil values. +// - makes sure the spec is in correct state +// +// should not be called directly, used in tests and unmarshalling +func (m *MongoDBMultiCluster) InitDefaults() { + m.Spec.Security = mdbv1.EnsureSecurity(m.Spec.Security) + + // TODO: add more default if need be + // ProjectName defaults to the name of the resource + if m.Spec.ProjectName == "" { + m.Spec.ProjectName = m.Name + } + + if m.Spec.Agent.StartupParameters == nil { + m.Spec.Agent.StartupParameters = map[string]string{} + } + + if m.Spec.AdditionalMongodConfig == nil || m.Spec.AdditionalMongodConfig.ToMap() == nil { + m.Spec.AdditionalMongodConfig = &mdbv1.AdditionalMongodConfig{} + } + + if m.Spec.CloudManagerConfig == nil { + m.Spec.CloudManagerConfig = mdbv1.NewOpsManagerConfig() + } + + if m.Spec.OpsManagerConfig == nil { + m.Spec.OpsManagerConfig = mdbv1.NewOpsManagerConfig() + } + + if m.Spec.Connectivity == nil { + m.Spec.Connectivity = mdbv1.NewConnectivity() + } + + m.Spec.Security = mdbv1.EnsureSecurity(m.Spec.Security) +} + +// Replicas returns the total number of MongoDB members running across all the clusters +func (m *MongoDBMultiSpec) Replicas() int { + num := 0 + for _, e := range m.ClusterSpecList { + num += e.Members + } + return num +} + +func (m *MongoDBMultiSpec) GetClusterDomain() string { + if m.ClusterDomain != "" { + return m.ClusterDomain + } + return "cluster.local" +} + +func (m *MongoDBMultiSpec) GetMongoDBVersion() string { + return m.Version +} + +func (m *MongoDBMultiSpec) GetSecurityAuthenticationModes() []string { + return m.GetSecurity().Authentication.GetModes() +} + +func (m *MongoDBMultiSpec) GetResourceType() mdbv1.ResourceType { + return m.ResourceType +} + +func (m *MongoDBMultiSpec) IsSecurityTLSConfigEnabled() bool { + return m.GetSecurity().IsTLSEnabled() +} + +func (m *MongoDBMultiSpec) GetFeatureCompatibilityVersion() *string { + return m.FeatureCompatibilityVersion +} + +func (m *MongoDBMultiSpec) GetHorizonConfig() []mdbv1.MongoDBHorizonConfig { + return m.Connectivity.ReplicaSetHorizons +} + +func (m *MongoDBMultiSpec) GetMemberOptions() []automationconfig.MemberOptions { + specList := m.GetClusterSpecList() + options := []automationconfig.MemberOptions{} + for _, item := range specList { + options = append(options, item.MemberConfig...) + } + return options +} + +func (m *MongoDBMultiSpec) MinimumMajorVersion() uint64 { + if m.FeatureCompatibilityVersion != nil && *m.FeatureCompatibilityVersion != "" { + fcv := *m.FeatureCompatibilityVersion + + // ignore errors here as the format of FCV/version is handled by CRD validation + semverFcv, _ := semver.Make(fmt.Sprintf("%s.0", fcv)) + return semverFcv.Major + } + semverVersion, _ := semver.Make(m.GetMongoDBVersion()) + return semverVersion.Major +} + +func (m *MongoDBMultiSpec) GetPersistence() bool { + if m.Persistent == nil { + return true + } + return *m.Persistent +} + +// GetClusterSpecList returns the cluster spec items. +func (m *MongoDBMultiSpec) GetClusterSpecList() []ClusterSpecItem { + return m.ClusterSpecList +} + +// GetDesiredSpecList returns the desired cluster spec list for a given reconcile operation. +// Returns the failerOver annotation if present else reads the cluster spec list from the CR. +func (m *MongoDBMultiCluster) GetDesiredSpecList() []ClusterSpecItem { + clusterSpecList := m.Spec.ClusterSpecList + + if val, ok := HasClustersToFailOver(m.GetAnnotations()); ok { + var clusterSpecOverride []ClusterSpecItem + + err := json.Unmarshal([]byte(val), &clusterSpecOverride) + if err != nil { + return clusterSpecList + } + clusterSpecList = clusterSpecOverride + } + return clusterSpecList +} + +// ClusterNum returns the index associated with a given clusterName, it assigns a unique id to each +// clustername taking into account addition and removal of clusters. We don't reuse cluster indexes since +// the clusters can be removed and then added back. +func (m *MongoDBMultiCluster) ClusterNum(clusterName string) int { + if m.Spec.Mapping == nil { + m.Spec.Mapping = make(map[string]int) + } + // first check if the entry exists in local map before making any API call + if val, ok := m.Spec.Mapping[clusterName]; ok { + return val + } + + // next check if the clusterName is present in the annotations + if bytes, ok := m.Annotations[LastClusterNumMapping]; ok { + json.Unmarshal([]byte(bytes), &m.Spec.Mapping) + + if val, ok := m.Spec.Mapping[clusterName]; ok { + return val + } + } + + index := getNextIndex(m.Spec.Mapping) + m.Spec.Mapping[clusterName] = index + return index +} + +// BuildConnectionString for a MultiCluster user. +// +// Not yet functional, because m.Service() is not defined. Waiting for CLOUDP-105817 +// to complete. +func (m *MongoDBMultiCluster) BuildConnectionString(username, password string, scheme connectionstring.Scheme, connectionParams map[string]string) string { + hostnames := make([]string, 0) + for _, spec := range m.Spec.GetClusterSpecList() { + hostnames = append(hostnames, dns.GetMultiClusterAgentHostnames(m.Name, m.Namespace, m.ClusterNum(spec.ClusterName), spec.Members, nil)...) + } + builder := connectionstring.Builder(). + SetName(m.Name). + SetNamespace(m.Namespace). + SetUsername(username). + SetPassword(password). + SetReplicas(m.Spec.Replicas()). + SetService(m.Name + "-svc"). + SetPort(m.Spec.GetAdditionalMongodConfig().GetPortOrDefault()). + SetVersion(m.Spec.GetMongoDBVersion()). + SetAuthenticationModes(m.Spec.GetSecurityAuthenticationModes()). + SetClusterDomain(m.Spec.GetClusterDomain()). + SetIsReplicaSet(true). + SetIsTLSEnabled(m.Spec.IsSecurityTLSConfigEnabled()). + SetMultiClusterHosts(hostnames). + SetScheme(scheme) + + return builder.Build() +} + +func (m *MongoDBMultiCluster) GetAuthenticationModes() []string { + return m.Spec.Security.Authentication.GetModes() +} + +// getNextIndex returns the next higher index from the current cluster indexes +func getNextIndex(m map[string]int) int { + maxi := -1 + + for _, val := range m { + maxi = intp.Max(maxi, val) + } + return maxi + 1 +} diff --git a/api/v1/mdbmulti/mongodbmulti_validation.go b/api/v1/mdbmulti/mongodbmulti_validation.go new file mode 100644 index 000000000..b3c78a43d --- /dev/null +++ b/api/v1/mdbmulti/mongodbmulti_validation.go @@ -0,0 +1,91 @@ +package mdbmulti + +import ( + "errors" + "fmt" + + v1 "github.com/10gen/ops-manager-kubernetes/api/v1" + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + runtime "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook" +) + +var _ webhook.Validator = &MongoDBMultiCluster{} + +func (m *MongoDBMultiCluster) ValidateCreate() error { + return m.ProcessValidationsOnReconcile(nil) +} + +func (m *MongoDBMultiCluster) ValidateUpdate(old runtime.Object) error { + return m.ProcessValidationsOnReconcile(old.(*MongoDBMultiCluster)) +} +func (m *MongoDBMultiCluster) ValidateDelete() error { + return nil +} + +func (m *MongoDBMultiCluster) ProcessValidationsOnReconcile(old *MongoDBMultiCluster) error { + for _, res := range m.RunValidations(old) { + if res.Level == v1.ErrorLevel { + return errors.New(res.Msg) + } + } + return nil +} + +func (m *MongoDBMultiCluster) RunValidations(old *MongoDBMultiCluster) []v1.ValidationResult { + multiClusterValidators := []func(ms MongoDBMultiSpec) v1.ValidationResult{ + validateUniqueClusterNames, + validateUniqueExternalDomains, + } + + var validationResults []v1.ValidationResult + + for _, validator := range multiClusterValidators { + res := validator(m.Spec) + if res.Level > 0 { + validationResults = append(validationResults, res) + } + } + + for _, validator := range mdbv1.CommonValidators() { + res := validator(m.Spec.DbCommonSpec) + if res.Level > 0 { + validationResults = append(validationResults, res) + } + } + + return validationResults +} + +func validateUniqueClusterNames(ms MongoDBMultiSpec) v1.ValidationResult { + present := make(map[string]struct{}) + + for _, e := range ms.ClusterSpecList { + if _, ok := present[e.ClusterName]; ok { + msg := fmt.Sprintf("Multiple clusters with the same name (%s) are not allowed", e.ClusterName) + return v1.ValidationError(msg) + } + present[e.ClusterName] = struct{}{} + } + return v1.ValidationSuccess() +} + +func validateUniqueExternalDomains(ms MongoDBMultiSpec) v1.ValidationResult { + if ms.ExternalAccessConfiguration != nil { + present := make(map[string]struct{}) + + for _, e := range ms.ClusterSpecList { + val := e.ExternalAccessConfiguration.ExternalDomain + if val == nil { + return v1.ValidationError("The externalDomain is not set for cluster name %s", e.ClusterName) + } + valAsString := *e.ExternalAccessConfiguration.ExternalDomain + if _, ok := present[valAsString]; ok { + msg := fmt.Sprintf("Multiple externalDomains with the same name (%s) are not allowed", valAsString) + return v1.ValidationError(msg) + } + present[valAsString] = struct{}{} + } + } + return v1.ValidationSuccess() +} diff --git a/api/v1/mdbmulti/mongodbmulti_validation_test.go b/api/v1/mdbmulti/mongodbmulti_validation_test.go new file mode 100644 index 000000000..96fe079a8 --- /dev/null +++ b/api/v1/mdbmulti/mongodbmulti_validation_test.go @@ -0,0 +1,81 @@ +package mdbmulti + +import ( + "testing" + + "k8s.io/utils/pointer" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/stretchr/testify/assert" +) + +func TestUniqueClusterNames(t *testing.T) { + mrs := DefaultMultiReplicaSetBuilder().Build() + mrs.Spec.ClusterSpecList = []ClusterSpecItem{ + { + ClusterName: "abc", + Members: 2, + }, + { + ClusterName: "def", + Members: 1, + }, + { + ClusterName: "abc", + Members: 1, + }, + } + + err := mrs.ValidateCreate() + assert.Equal(t, "Multiple clusters with the same name (abc) are not allowed", err.Error()) +} + +func TestUniqueExternalDomains(t *testing.T) { + mrs := DefaultMultiReplicaSetBuilder().Build() + mrs.Spec.ExternalAccessConfiguration = &mdbv1.ExternalAccessConfiguration{} + mrs.Spec.ClusterSpecList = []ClusterSpecItem{ + { + ClusterName: "1", + Members: 1, + ExternalAccessConfiguration: mdbv1.ExternalAccessConfiguration{ExternalDomain: pointer.String("test")}, + }, + { + ClusterName: "2", + Members: 1, + ExternalAccessConfiguration: mdbv1.ExternalAccessConfiguration{ExternalDomain: pointer.String("test")}, + }, + { + ClusterName: "3", + Members: 1, + ExternalAccessConfiguration: mdbv1.ExternalAccessConfiguration{ExternalDomain: pointer.String("test")}, + }, + } + + err := mrs.ValidateCreate() + assert.Equal(t, "Multiple externalDomains with the same name (test) are not allowed", err.Error()) +} + +func TestMongoDBMultiValidattionHorzonsWithoutTLS(t *testing.T) { + replicaSetHorizons := []mdbv1.MongoDBHorizonConfig{ + {"my-horizon": "my-db.com:12345"}, + {"my-horizon": "my-db.com:12342"}, + {"my-horizon": "my-db.com:12346"}, + } + + mrs := DefaultMultiReplicaSetBuilder().Build() + mrs.Spec.Connectivity = &mdbv1.MongoDBConnectivity{ + ReplicaSetHorizons: replicaSetHorizons, + } + + err := mrs.ValidateCreate() + assert.Equal(t, "TLS must be enabled in order to use replica set horizons", err.Error()) +} + +func TestSpecProjectOnlyOneValue(t *testing.T) { + mrs := DefaultMultiReplicaSetBuilder().Build() + mrs.Spec.OpsManagerConfig = &mdbv1.PrivateCloudConfig{ + ConfigMapRef: mdbv1.ConfigMapRef{Name: "cloud-manager"}, + } + err := mrs.ValidateCreate() + assert.NoError(t, err) +} diff --git a/api/v1/mdbmulti/mongodbmultibuilder.go b/api/v1/mdbmulti/mongodbmultibuilder.go new file mode 100644 index 000000000..9114ce39f --- /dev/null +++ b/api/v1/mdbmulti/mongodbmultibuilder.go @@ -0,0 +1,92 @@ +package mdbmulti + +import ( + "fmt" + "math/rand" + "time" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/mock" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type MultiReplicaSetBuilder struct { + *MongoDBMultiCluster +} + +func DefaultMultiReplicaSetBuilder() *MultiReplicaSetBuilder { + spec := MongoDBMultiSpec{ + DbCommonSpec: mdbv1.DbCommonSpec{ + Connectivity: &mdbv1.MongoDBConnectivity{}, + Version: "5.0.0", + Persistent: util.BooleanRef(false), + ConnectionSpec: mdbv1.ConnectionSpec{ + SharedConnectionSpec: mdbv1.SharedConnectionSpec{ + OpsManagerConfig: &mdbv1.PrivateCloudConfig{ + ConfigMapRef: mdbv1.ConfigMapRef{ + Name: mock.TestProjectConfigMapName, + }, + }}, + Credentials: mock.TestCredentialsSecretName, + }, + ResourceType: mdbv1.ReplicaSet, + Security: &mdbv1.Security{ + TLSConfig: &mdbv1.TLSConfig{}, + Authentication: &mdbv1.Authentication{ + Modes: []string{}, + }, + Roles: []mdbv1.MongoDbRole{}, + }, + }, + DuplicateServiceObjects: util.BooleanRef(false), + } + + mrs := &MongoDBMultiCluster{Spec: spec, ObjectMeta: metav1.ObjectMeta{Name: "temple", Namespace: mock.TestNamespace}} + return &MultiReplicaSetBuilder{mrs} +} + +func (m *MultiReplicaSetBuilder) Build() *MongoDBMultiCluster { + // initialize defaults + res := m.MongoDBMultiCluster.DeepCopy() + res.InitDefaults() + return res +} + +func (m *MultiReplicaSetBuilder) SetSecurity(s *mdbv1.Security) *MultiReplicaSetBuilder { + m.Spec.Security = s + return m +} + +func (m *MultiReplicaSetBuilder) SetClusterSpecList(clusters []string) *MultiReplicaSetBuilder { + rand.Seed(time.Now().UnixNano()) + + for _, e := range clusters { + m.Spec.ClusterSpecList = append(m.Spec.ClusterSpecList, ClusterSpecItem{ + ClusterName: e, + Members: rand.Intn(5) + 1, // number of cluster members b/w 1 to 5 + }) + } + return m +} + +func (m *MultiReplicaSetBuilder) SetExternalAccess(configuration mdbv1.ExternalAccessConfiguration, externalDomainTemplate string) *MultiReplicaSetBuilder { + m.Spec.ExternalAccessConfiguration = &configuration + + for i := range m.Spec.ClusterSpecList { + s := fmt.Sprintf(externalDomainTemplate, i) + m.Spec.ClusterSpecList[i].ExternalAccessConfiguration.ExternalDomain = &s + } + + return m +} + +func (m *MultiReplicaSetBuilder) SetConnectionSpec(spec mdbv1.ConnectionSpec) *MultiReplicaSetBuilder { + m.Spec.ConnectionSpec = spec + return m +} + +func (m *MultiReplicaSetBuilder) SetBackup(backupSpec mdbv1.Backup) *MultiReplicaSetBuilder { + m.Spec.Backup = &backupSpec + return m +} diff --git a/api/v1/mdbmulti/zz_generated.deepcopy.go b/api/v1/mdbmulti/zz_generated.deepcopy.go new file mode 100644 index 000000000..f926fb8ae --- /dev/null +++ b/api/v1/mdbmulti/zz_generated.deepcopy.go @@ -0,0 +1,247 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright 2021. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package mdbmulti + +import ( + "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/api/v1/status" + v1 "github.com/mongodb/mongodb-kubernetes-operator/api/v1" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/automationconfig" + "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterSpecItem) DeepCopyInto(out *ClusterSpecItem) { + *out = *in + if in.ExposedExternally != nil { + in, out := &in.ExposedExternally, &out.ExposedExternally + *out = new(bool) + **out = **in + } + in.ExternalAccessConfiguration.DeepCopyInto(&out.ExternalAccessConfiguration) + if in.MemberConfig != nil { + in, out := &in.MemberConfig, &out.MemberConfig + *out = make([]automationconfig.MemberOptions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.StatefulSetConfiguration != nil { + in, out := &in.StatefulSetConfiguration, &out.StatefulSetConfiguration + *out = new(v1.StatefulSetConfiguration) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterSpecItem. +func (in *ClusterSpecItem) DeepCopy() *ClusterSpecItem { + if in == nil { + return nil + } + out := new(ClusterSpecItem) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterStatusItem) DeepCopyInto(out *ClusterStatusItem) { + *out = *in + in.Common.DeepCopyInto(&out.Common) + if in.Warnings != nil { + in, out := &in.Warnings, &out.Warnings + *out = make([]status.Warning, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterStatusItem. +func (in *ClusterStatusItem) DeepCopy() *ClusterStatusItem { + if in == nil { + return nil + } + out := new(ClusterStatusItem) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterStatusList) DeepCopyInto(out *ClusterStatusList) { + *out = *in + if in.ClusterStatuses != nil { + in, out := &in.ClusterStatuses, &out.ClusterStatuses + *out = make([]ClusterStatusItem, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterStatusList. +func (in *ClusterStatusList) DeepCopy() *ClusterStatusList { + if in == nil { + return nil + } + out := new(ClusterStatusList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MongoDBMultiCluster) DeepCopyInto(out *MongoDBMultiCluster) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Status.DeepCopyInto(&out.Status) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MongoDBMultiCluster. +func (in *MongoDBMultiCluster) DeepCopy() *MongoDBMultiCluster { + if in == nil { + return nil + } + out := new(MongoDBMultiCluster) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MongoDBMultiCluster) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MongoDBMultiClusterList) DeepCopyInto(out *MongoDBMultiClusterList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]MongoDBMultiCluster, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MongoDBMultiClusterList. +func (in *MongoDBMultiClusterList) DeepCopy() *MongoDBMultiClusterList { + if in == nil { + return nil + } + out := new(MongoDBMultiClusterList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MongoDBMultiClusterList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MongoDBMultiSpec) DeepCopyInto(out *MongoDBMultiSpec) { + *out = *in + in.DbCommonSpec.DeepCopyInto(&out.DbCommonSpec) + if in.DuplicateServiceObjects != nil { + in, out := &in.DuplicateServiceObjects, &out.DuplicateServiceObjects + *out = new(bool) + **out = **in + } + if in.ClusterSpecList != nil { + in, out := &in.ClusterSpecList, &out.ClusterSpecList + *out = make([]ClusterSpecItem, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Mapping != nil { + in, out := &in.Mapping, &out.Mapping + *out = make(map[string]int, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MongoDBMultiSpec. +func (in *MongoDBMultiSpec) DeepCopy() *MongoDBMultiSpec { + if in == nil { + return nil + } + out := new(MongoDBMultiSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MongoDBMultiStatus) DeepCopyInto(out *MongoDBMultiStatus) { + *out = *in + in.Common.DeepCopyInto(&out.Common) + in.ClusterStatusList.DeepCopyInto(&out.ClusterStatusList) + if in.BackupStatus != nil { + in, out := &in.BackupStatus, &out.BackupStatus + *out = new(mdb.BackupStatus) + **out = **in + } + if in.Warnings != nil { + in, out := &in.Warnings, &out.Warnings + *out = make([]status.Warning, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MongoDBMultiStatus. +func (in *MongoDBMultiStatus) DeepCopy() *MongoDBMultiStatus { + if in == nil { + return nil + } + out := new(MongoDBMultiStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MultiReplicaSetBuilder) DeepCopyInto(out *MultiReplicaSetBuilder) { + *out = *in + if in.MongoDBMultiCluster != nil { + in, out := &in.MongoDBMultiCluster, &out.MongoDBMultiCluster + *out = new(MongoDBMultiCluster) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MultiReplicaSetBuilder. +func (in *MultiReplicaSetBuilder) DeepCopy() *MultiReplicaSetBuilder { + if in == nil { + return nil + } + out := new(MultiReplicaSetBuilder) + in.DeepCopyInto(out) + return out +} diff --git a/api/v1/om/appdb_types.go b/api/v1/om/appdb_types.go new file mode 100644 index 000000000..c76297c5d --- /dev/null +++ b/api/v1/om/appdb_types.go @@ -0,0 +1,454 @@ +package om + +import ( + "encoding/json" + "fmt" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + userv1 "github.com/10gen/ops-manager-kubernetes/api/v1/user" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/connectionstring" + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/vault" + mdbcv1 "github.com/mongodb/mongodb-kubernetes-operator/api/v1" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/authentication/scram" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/automationconfig" + appsv1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + appDBKeyfilePath = "/var/lib/mongodb-mms-automation/authentication/keyfile" +) + +type AppDBSpec struct { + // +kubebuilder:validation:Pattern=^[0-9]+.[0-9]+.[0-9]+(-.+)?$|^$ + Version string `json:"version"` + // Amount of members for this MongoDB Replica Set + // +kubebuilder:validation:Maximum=50 + // +kubebuilder:validation:Minimum=3 + Members int `json:"members,omitempty"` + PodSpec *mdbv1.MongoDbPodSpec `json:"podSpec,omitempty"` + FeatureCompatibilityVersion *string `json:"featureCompatibilityVersion,omitempty"` + + // +optional + Security *mdbv1.Security `json:"security,omitempty"` + ClusterDomain string `json:"clusterDomain,omitempty"` + // +kubebuilder:validation:Enum=Standalone;ReplicaSet;ShardedCluster + ResourceType mdbv1.ResourceType `json:"type,omitempty"` + + Connectivity *mdbv1.MongoDBConnectivity `json:"connectivity,omitempty"` + // AdditionalMongodConfig is additional configuration that can be passed to + // each data-bearing mongod at runtime. Uses the same structure as the mongod + // configuration file: + // https://docs.mongodb.com/manual/reference/configuration-options/ + // +kubebuilder:pruning:PreserveUnknownFields + // +optional + AdditionalMongodConfig *mdbv1.AdditionalMongodConfig `json:"additionalMongodConfig,omitempty"` + + // specify startup flags for the AutomationAgent and MonitoringAgent + AutomationAgent mdbv1.AgentConfig `json:"agent,omitempty"` + + // specify startup flags for just the MonitoringAgent. These take precedence over + // the flags set in AutomationAgent + MonitoringAgent mdbv1.AgentConfig `json:"monitoringAgent,omitempty"` + ConnectionSpec `json:",inline"` + + // PasswordSecretKeyRef contains a reference to the secret which contains the password + // for the mongodb-ops-manager SCRAM-SHA user + PasswordSecretKeyRef *userv1.SecretKeyRef `json:"passwordSecretKeyRef,omitempty"` + + // Enables Prometheus integration on the AppDB. + Prometheus *mdbcv1.Prometheus `json:"prometheus,omitempty"` + + // transient fields. These fields are cleaned before serialization, see 'MarshalJSON()' + // note, that we cannot include the 'OpsManager' instance here as this creates circular dependency and problems with + // 'DeepCopy' + + OpsManagerName string `json:"-"` + Namespace string `json:"-"` + // this is an optional service, it will get the name "-service" in case not provided + Service string `json:"service,omitempty"` + + // AutomationConfigOverride holds any fields that will be merged on top of the Automation Config + // that the operator creates for the AppDB. Currently only the process.disabled field is recognized. + AutomationConfigOverride *mdbcv1.AutomationConfigOverride `json:"automationConfig,omitempty"` + + UpdateStrategyType appsv1.StatefulSetUpdateStrategyType `json:"-"` + + // MemberConfig + // +optional + MemberConfig []automationconfig.MemberOptions `json:"memberConfig,omitempty"` +} + +func (m *AppDBSpec) GetAgentLogLevel() mdbcv1.LogLevel { + return mdbcv1.LogLevel(m.AutomationAgent.LogLevel) +} + +func (m *AppDBSpec) GetAgentMaxLogFileDurationHours() int { + return m.AutomationAgent.MaxLogFileDurationHours +} + +// ObjectKey returns the client.ObjectKey with m.OpsManagerName because the name is used to identify the object to enqueue and reconcile. +func (m *AppDBSpec) ObjectKey() client.ObjectKey { + return kube.ObjectKey(m.Namespace, m.OpsManagerName) +} + +// GetConnectionSpec returns nil because no connection spec for appDB is implemented for the watcher setup +func (m *AppDBSpec) GetConnectionSpec() *mdbv1.ConnectionSpec { + return nil +} + +func (m *AppDBSpec) GetExternalDomain() *string { + return nil +} + +func (m *AppDBSpec) GetMongodConfiguration() mdbcv1.MongodConfiguration { + mongodConfig := mdbcv1.NewMongodConfiguration() + if m.GetAdditionalMongodConfig() == nil || m.AdditionalMongodConfig.ToMap() == nil { + return mongodConfig + } + for k, v := range m.AdditionalMongodConfig.ToMap() { + mongodConfig.SetOption(k, v) + } + return mongodConfig +} + +func (m *AppDBSpec) GetHorizonConfig() []mdbv1.MongoDBHorizonConfig { + return nil // no horizon support for AppDB currently +} + +func (m *AppDBSpec) GetAdditionalMongodConfig() *mdbv1.AdditionalMongodConfig { + if m.AdditionalMongodConfig != nil { + return m.AdditionalMongodConfig + } + return &mdbv1.AdditionalMongodConfig{} +} + +func (m *AppDBSpec) GetMemberOptions() []automationconfig.MemberOptions { + return m.MemberConfig +} + +// GetAgentPasswordSecretNamespacedName returns the NamespacedName for the secret +// which contains the Automation Agent's password. +func (m *AppDBSpec) GetAgentPasswordSecretNamespacedName() types.NamespacedName { + return types.NamespacedName{ + Namespace: m.Namespace, + Name: m.Name() + "-agent-password", + } +} + +// GetAgentKeyfileSecretNamespacedName returns the NamespacedName for the secret +// which contains the keyfile. +func (m *AppDBSpec) GetAgentKeyfileSecretNamespacedName() types.NamespacedName { + return types.NamespacedName{ + Namespace: m.Namespace, + Name: m.Name() + "-keyfile", + } +} + +// GetScramOptions returns a set of Options which is used to configure Scram Sha authentication +// in the AppDB. +func (m *AppDBSpec) GetScramOptions() scram.Options { + return scram.Options{ + AuthoritativeSet: false, + KeyFile: appDBKeyfilePath, + AutoAuthMechanisms: []string{ + scram.Sha256, + scram.Sha1, + }, + AgentName: util.AutomationAgentName, + AutoAuthMechanism: scram.Sha1, + } +} + +// GetScramUsers returns a list of all scram users for this deployment. +// in this case it is just the Ops Manager user for the AppDB. +func (m *AppDBSpec) GetScramUsers() []scram.User { + passwordSecretName := m.GetOpsManagerUserPasswordSecretName() + if m.PasswordSecretKeyRef != nil && m.PasswordSecretKeyRef.Name != "" { + passwordSecretName = m.PasswordSecretKeyRef.Name + } + return []scram.User{ + { + Username: util.OpsManagerMongoDBUserName, + Database: util.DefaultUserDatabase, + // required roles for the AppDB user are outlined in the documentation + // https://docs.opsmanager.mongodb.com/current/tutorial/prepare-backing-mongodb-instances/#replica-set-security + Roles: []scram.Role{ + { + Name: "readWriteAnyDatabase", + Database: "admin", + }, + { + Name: "dbAdminAnyDatabase", + Database: "admin", + }, + { + Name: "clusterMonitor", + Database: "admin", + }, + // Enables backup and restoration roles + // https://docs.mongodb.com/manual/reference/built-in-roles/#backup-and-restoration-roles + { + Name: "backup", + Database: "admin", + }, + { + Name: "restore", + Database: "admin", + }, + // Allows user to do db.fsyncLock required by CLOUDP-78890 + // https://docs.mongodb.com/manual/reference/built-in-roles/#hostManager + { + Name: "hostManager", + Database: "admin", + }, + }, + PasswordSecretKey: m.GetOpsManagerUserPasswordSecretKey(), + PasswordSecretName: passwordSecretName, + ScramCredentialsSecretName: m.OpsManagerUserScramCredentialsName(), + }, + } +} + +func (m *AppDBSpec) NamespacedName() types.NamespacedName { + return types.NamespacedName{Name: m.Name(), Namespace: m.Namespace} +} + +// GetOpsManagerUserPasswordSecretName returns the name of the secret +// that will store the Ops Manager user's password. +func (m *AppDBSpec) GetOpsManagerUserPasswordSecretName() string { + return m.Name() + "-om-password" +} + +// GetOpsManagerUserPasswordSecretKey returns the key that should be used to map to the Ops Manager user's +// password in the secret. +func (m *AppDBSpec) GetOpsManagerUserPasswordSecretKey() string { + if m.PasswordSecretKeyRef != nil && m.PasswordSecretKeyRef.Key != "" { + return m.PasswordSecretKeyRef.Key + } + return util.DefaultAppDbPasswordKey +} + +// OpsManagerUserScramCredentialsName returns the name of the Secret +// which will store the Ops Manager MongoDB user's scram credentials. +func (m *AppDBSpec) OpsManagerUserScramCredentialsName() string { + return m.Name() + "-om-user-scram-credentials" +} + +type ConnectionSpec struct { + mdbv1.SharedConnectionSpec `json:",inline"` + + // Credentials differ to mdbv1.ConnectionSpec because they are optional here. + + // Name of the Secret holding credentials information + Credentials string `json:"credentials,omitempty"` +} + +type AppDbBuilder struct { + appDb *AppDBSpec +} + +// GetMongoDBVersion returns the version of the MongoDB. +func (m *AppDBSpec) GetMongoDBVersion() string { + return m.Version +} + +func (m *AppDBSpec) GetClusterDomain() string { + if m.ClusterDomain != "" { + return m.ClusterDomain + } + return "cluster.local" +} + +// Replicas returns the number of "user facing" replicas of the MongoDB resource. This method can be used for +// constructing the mongodb URL for example. +// 'Members' would be a more consistent function but go doesn't allow to have the same +// For AppDB there is a validation that number of members is in the range [3, 50] +func (m *AppDBSpec) Replicas() int { + return m.Members +} + +func (m *AppDBSpec) GetSecurityAuthenticationModes() []string { + return m.GetSecurity().Authentication.GetModes() +} + +func (m *AppDBSpec) GetResourceType() mdbv1.ResourceType { + return m.ResourceType +} + +func (m *AppDBSpec) IsSecurityTLSConfigEnabled() bool { + return m.GetSecurity().IsTLSEnabled() +} + +func (m *AppDBSpec) GetFeatureCompatibilityVersion() *string { + return m.FeatureCompatibilityVersion +} + +func (m *AppDBSpec) GetSecurity() *mdbv1.Security { + if m.Security == nil { + return &mdbv1.Security{} + } + return m.Security +} + +func (m *AppDBSpec) GetTLSConfig() *mdbv1.TLSConfig { + if m.Security == nil || m.Security.TLSConfig == nil { + return &mdbv1.TLSConfig{} + } + + return m.Security.TLSConfig +} +func DefaultAppDbBuilder() *AppDbBuilder { + appDb := &AppDBSpec{ + Version: "4.2.0", + Members: 3, + PodSpec: &mdbv1.MongoDbPodSpec{}, + PasswordSecretKeyRef: &userv1.SecretKeyRef{}, + } + return &AppDbBuilder{appDb: appDb} +} + +func (b *AppDbBuilder) Build() *AppDBSpec { + return b.appDb.DeepCopy() +} + +func (m *AppDBSpec) GetSecretName() string { + return m.Name() + "-password" +} + +func (m *AppDBSpec) UnmarshalJSON(data []byte) error { + type MongoDBJSON *AppDBSpec + if err := json.Unmarshal(data, (MongoDBJSON)(m)); err != nil { + return err + } + + // if a reference is specified without a key, we will default to "password" + if m.PasswordSecretKeyRef != nil && m.PasswordSecretKeyRef.Key == "" { + m.PasswordSecretKeyRef.Key = util.DefaultAppDbPasswordKey + } + + m.ConnectionSpec.Credentials = "" + m.ConnectionSpec.CloudManagerConfig = nil + m.ConnectionSpec.OpsManagerConfig = nil + + // all resources have a pod spec + if m.PodSpec == nil { + m.PodSpec = mdbv1.NewMongoDbPodSpec() + } + return nil +} + +// Name returns the name of the StatefulSet for the AppDB +func (m *AppDBSpec) Name() string { + return m.OpsManagerName + "-db" +} + +func (m *AppDBSpec) ProjectIDConfigMapName() string { + return m.Name() + "-project-id" +} + +func (m *AppDBSpec) ServiceName() string { + if m.Service == "" { + return m.Name() + "-svc" + } + return m.Service +} + +func (m *AppDBSpec) AutomationConfigSecretName() string { + return m.Name() + "-config" +} + +func (m *AppDBSpec) MonitoringAutomationConfigSecretName() string { + return m.Name() + "-monitoring-config" +} + +// This function is used in community to determine whether we need to create a single +// volume for data+logs or two separate ones +// unless spec.PodSpec.Persistence.MultipleConfig is set, a single volume will be created +func (m *AppDBSpec) HasSeparateDataAndLogsVolumes() bool { + p := m.PodSpec.Persistence + return p != nil && (p.MultipleConfig != nil && p.SingleConfig == nil) +} + +func (m *AppDBSpec) GetUpdateStrategyType() appsv1.StatefulSetUpdateStrategyType { + return m.UpdateStrategyType +} + +// GetCAConfigMapName returns the name of the ConfigMap which contains +// the CA which will recognize the certificates used to connect to the AppDB +// deployment +func (m *AppDBSpec) GetCAConfigMapName() string { + security := m.Security + if security != nil && security.TLSConfig != nil { + return security.TLSConfig.CA + } + return "" +} + +// GetTlsCertificatesSecretName returns the name of the secret +// which holds the certificates used to connect to the AppDB +func (m *AppDBSpec) GetTlsCertificatesSecretName() string { + return m.GetSecurity().MemberCertificateSecretName(m.Name()) +} + +func (m *AppDBSpec) GetName() string { + return m.Name() +} +func (m *AppDBSpec) GetNamespace() string { + return m.Namespace +} + +func (m *AppDBSpec) DataVolumeName() string { + return "data" +} + +func (m *AppDBSpec) LogsVolumeName() string { + return "logs" +} + +func (m *AppDBSpec) NeedsAutomationConfigVolume() bool { + return !vault.IsVaultSecretBackend() +} + +func (m *AppDBSpec) AutomationConfigConfigMapName() string { + return fmt.Sprintf("%s-automation-config-version", m.Name()) +} + +func (m *AppDBSpec) MonitoringAutomationConfigConfigMapName() string { + return fmt.Sprintf("%s-monitoring-automation-config-version", m.Name()) +} + +// GetSecretsMountedIntoPod returns the list of strings mounted into the pod that we need to watch. +func (m *AppDBSpec) GetSecretsMountedIntoPod() []string { + secrets := []string{} + if m.PasswordSecretKeyRef != nil { + secrets = append(secrets, m.PasswordSecretKeyRef.Name) + } + + if m.Security.IsTLSEnabled() { + secrets = append(secrets, m.GetTlsCertificatesSecretName()) + } + return secrets +} + +func (m *AppDBSpec) BuildConnectionURL(username, password string, scheme connectionstring.Scheme, connectionParams map[string]string) string { + builder := connectionstring.Builder(). + SetName(m.Name()). + SetNamespace(m.Namespace). + SetUsername(username). + SetPassword(password). + SetReplicas(m.Replicas()). + SetService(m.ServiceName()). + SetVersion(m.GetMongoDBVersion()). + SetAuthenticationModes(m.GetSecurityAuthenticationModes()). + SetClusterDomain(m.GetClusterDomain()). + SetIsReplicaSet(true). + SetIsTLSEnabled(m.IsSecurityTLSConfigEnabled()). + SetConnectionParams(connectionParams). + SetScheme(scheme) + + return builder.Build() +} diff --git a/api/v1/om/doc.go b/api/v1/om/doc.go new file mode 100644 index 000000000..13d6e428a --- /dev/null +++ b/api/v1/om/doc.go @@ -0,0 +1,4 @@ +package om + +// +k8s:deepcopy-gen=package +// +versionName=v1 diff --git a/api/v1/om/groupversion_info.go b/api/v1/om/groupversion_info.go new file mode 100644 index 000000000..0097c2ffb --- /dev/null +++ b/api/v1/om/groupversion_info.go @@ -0,0 +1,20 @@ +// Package v1 contains API Schema definitions for the mongodb v1 API group +// +kubebuilder:object:generate=true +// +groupName=mongodb.com +package om + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects + GroupVersion = schema.GroupVersion{Group: "mongodb.com", Version: "v1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/api/v1/om/opsmanager_types.go b/api/v1/om/opsmanager_types.go new file mode 100644 index 000000000..c0334faed --- /dev/null +++ b/api/v1/om/opsmanager_types.go @@ -0,0 +1,794 @@ +package om + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/secrets" + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/annotations" + + v1 "github.com/10gen/ops-manager-kubernetes/api/v1" + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/api/v1/status" + userv1 "github.com/10gen/ops-manager-kubernetes/api/v1/user" + "sigs.k8s.io/controller-runtime/pkg/manager" + + "github.com/10gen/ops-manager-kubernetes/pkg/dns" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" + mdbc "github.com/mongodb/mongodb-kubernetes-operator/api/v1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func init() { + v1.SchemeBuilder.Register(&MongoDBOpsManager{}, &MongoDBOpsManagerList{}) +} + +const ( + queryableBackupConfigPath string = "brs.queryable.proxyPort" + queryableBackupDefaultPort int32 = 25999 +) + +// The MongoDBOpsManager resource allows you to deploy Ops Manager within your Kubernetes cluster + +// +k8s:deepcopy-gen=true +// +kubebuilder:object:root=true +// +k8s:openapi-gen=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:path=opsmanagers,scope=Namespaced,shortName=om,singular=opsmanager +// +kubebuilder:printcolumn:name="Replicas",type="integer",JSONPath=".spec.replicas",description="The number of replicas of MongoDBOpsManager." +// +kubebuilder:printcolumn:name="Version",type="string",JSONPath=".spec.version",description="The version of MongoDBOpsManager." +// +kubebuilder:printcolumn:name="State (OpsManager)",type="string",JSONPath=".status.opsManager.phase",description="The current state of the MongoDBOpsManager." +// +kubebuilder:printcolumn:name="State (AppDB)",type="string",JSONPath=".status.applicationDatabase.phase",description="The current state of the MongoDBOpsManager Application Database." +// +kubebuilder:printcolumn:name="State (Backup)",type="string",JSONPath=".status.backup.phase",description="The current state of the MongoDBOpsManager Backup Daemon." +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="The time since the MongoDBOpsManager resource was created." +// +kubebuilder:printcolumn:name="Warnings",type="string",JSONPath=".status.warnings",description="Warnings." +type MongoDBOpsManager struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec MongoDBOpsManagerSpec `json:"spec"` + // +optional + Status MongoDBOpsManagerStatus `json:"status"` +} + +func (om *MongoDBOpsManager) ForcedIndividualScaling() bool { + return false +} + +func (om MongoDBOpsManager) AddValidationToManager(m manager.Manager) error { + return ctrl.NewWebhookManagedBy(m).For(&om).Complete() +} + +func (om MongoDBOpsManager) GetAppDBProjectConfig(client secrets.SecretClient) (mdbv1.ProjectConfig, error) { + var operatorVaultSecretPath string + if client.VaultClient != nil { + operatorVaultSecretPath = client.VaultClient.OperatorSecretPath() + } + secretName, err := om.APIKeySecretName(client, operatorVaultSecretPath) + if err != nil { + return mdbv1.ProjectConfig{}, err + } + + return mdbv1.ProjectConfig{ + BaseURL: om.CentralURL(), + ProjectName: om.Spec.AppDB.Name(), + Credentials: secretName, + }, nil +} + +// +k8s:deepcopy-gen=true +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type MongoDBOpsManagerList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + Items []MongoDBOpsManager `json:"items"` +} + +type MongoDBOpsManagerSpec struct { + // The configuration properties passed to Ops Manager/Backup Daemon + // +optional + Configuration map[string]string `json:"configuration,omitempty"` + + Version string `json:"version"` + // +optional + // +kubebuilder:validation:Minimum=1 + Replicas int `json:"replicas"` + // Deprecated: This has been replaced by the ClusterDomain which should be + // used instead + // +kubebuilder:validation:Format="hostname" + ClusterName string `json:"clusterName,omitempty"` + // +optional + // +kubebuilder:validation:Format="hostname" + ClusterDomain string `json:"clusterDomain,omitempty"` + + // AdminSecret is the secret for the first admin user to create + // has the fields: "Username", "Password", "FirstName", "LastName" + AdminSecret string `json:"adminCredentials,omitempty"` + AppDB AppDBSpec `json:"applicationDatabase"` + + // Custom JVM parameters passed to the Ops Manager JVM + // +optional + JVMParams []string `json:"jvmParameters,omitempty"` + + // Backup + // +optional + Backup *MongoDBOpsManagerBackup `json:"backup,omitempty"` + + // MongoDBOpsManagerExternalConnectivity if sets allows for the creation of a Service for + // accessing this Ops Manager resource from outside the Kubernetes cluster. + // +optional + MongoDBOpsManagerExternalConnectivity *MongoDBOpsManagerServiceDefinition `json:"externalConnectivity,omitempty"` + + // Configure HTTPS. + // +optional + Security *MongoDBOpsManagerSecurity `json:"security,omitempty"` + + // Configure custom StatefulSet configuration + // +optional + StatefulSetConfiguration *mdbc.StatefulSetConfiguration `json:"statefulSet,omitempty"` +} + +type MongoDBOpsManagerSecurity struct { + // +optional + TLS MongoDBOpsManagerTLS `json:"tls"` + + // +optional + CertificatesSecretsPrefix string `json:"certsSecretPrefix"` +} + +type MongoDBOpsManagerTLS struct { + // +optional + SecretRef TLSSecretRef `json:"secretRef"` + // +optional + CA string `json:"ca"` +} + +type TLSSecretRef struct { + // +kubebuilder:validation:Required + Name string `json:"name"` +} + +func (ms MongoDBOpsManagerSpec) IsKmipEnabled() bool { + if ms.Backup == nil || ms.Backup.Enabled == false || ms.Backup.Encryption == nil || ms.Backup.Encryption.Kmip == nil { + return false + } + return true +} + +func (ms MongoDBOpsManagerSpec) GetClusterDomain() string { + if ms.ClusterDomain != "" { + return ms.ClusterDomain + } + if ms.ClusterName != "" { + return ms.ClusterName + } + return "cluster.local" +} + +func (ms MongoDBOpsManagerSpec) GetOpsManagerCA() string { + if ms.Security != nil { + return ms.Security.TLS.CA + } + return "" +} + +func (ms MongoDBOpsManagerSpec) GetAppDbCA() string { + if ms.AppDB.Security != nil && ms.AppDB.Security.TLSConfig != nil { + return ms.AppDB.Security.TLSConfig.CA + } + return "" +} + +func (m MongoDBOpsManager) ObjectKey() client.ObjectKey { + return kube.ObjectKey(m.Namespace, m.Name) +} + +func (m MongoDBOpsManager) AppDBStatefulSetObjectKey() client.ObjectKey { + return kube.ObjectKey(m.Namespace, m.Spec.AppDB.Name()) +} + +// MongoDBOpsManagerServiceDefinition struct that defines the mechanism by which this Ops Manager resource +// is exposed, via a Service, to the outside of the Kubernetes Cluster. +type MongoDBOpsManagerServiceDefinition struct { + // Type of the `Service` to be created. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=LoadBalancer;NodePort + Type corev1.ServiceType `json:"type"` + + // Port in which this `Service` will listen to, this applies to `NodePort`. + Port int32 `json:"port,omitempty"` + + // LoadBalancerIP IP that will be assigned to this LoadBalancer. + LoadBalancerIP string `json:"loadBalancerIP,omitempty"` + + // ExternalTrafficPolicy mechanism to preserve the client source IP. + // Only supported on GCE and Google Kubernetes Engine. + // +kubebuilder:validation:Enum=Cluster;Local + ExternalTrafficPolicy corev1.ServiceExternalTrafficPolicyType `json:"externalTrafficPolicy,omitempty"` + + // Annotations is a list of annotations to be directly passed to the Service object. + Annotations map[string]string `json:"annotations,omitempty"` +} + +// MongoDBOpsManagerBackup backup structure for Ops Manager resources +type MongoDBOpsManagerBackup struct { + // Enabled indicates if Backups will be enabled for this Ops Manager. + Enabled bool `json:"enabled"` + ExternalServiceEnabled *bool `json:"externalServiceEnabled,omitempty"` + + // Members indicate the number of backup daemon pods to create. + // +optional + // +kubebuilder:validation:Minimum=1 + Members int `json:"members,omitempty"` + + // Assignment Labels set in the Ops Manager + // +optional + AssignmentLabels []string `json:"assignmentLabels,omitempty"` + + // HeadDB specifies configuration options for the HeadDB + HeadDB *mdbv1.PersistenceConfig `json:"headDB,omitempty"` + JVMParams []string `json:"jvmParameters,omitempty"` + + // S3OplogStoreConfigs describes the list of s3 oplog store configs used for backup. + S3OplogStoreConfigs []S3Config `json:"s3OpLogStores,omitempty"` + + // OplogStoreConfigs describes the list of oplog store configs used for backup + OplogStoreConfigs []DataStoreConfig `json:"opLogStores,omitempty"` + BlockStoreConfigs []DataStoreConfig `json:"blockStores,omitempty"` + S3Configs []S3Config `json:"s3Stores,omitempty"` + FileSystemStoreConfigs []FileSystemStoreConfig `json:"fileSystemStores,omitempty"` + StatefulSetConfiguration *mdbc.StatefulSetConfiguration `json:"statefulSet,omitempty"` + + // QueryableBackupSecretRef references the secret which contains the pem file which is used + // for queryable backup. This will be mounted into the Ops Manager pod. + // +optional + QueryableBackupSecretRef SecretRef `json:"queryableBackupSecretRef,omitempty"` + + // Encryption settings + // +optional + Encryption *Encryption `json:"encryption,omitempty"` +} + +// Encryption contains encryption settings +type Encryption struct { + // Kmip corresponds to the KMIP configuration assigned to the Ops Manager Project's configuration. + // +optional + Kmip *KmipConfig `json:"kmip,omitempty"` +} + +// KmipConfig contains Project-level KMIP configuration +type KmipConfig struct { + // KMIP Server configuration + Server v1.KmipServerConfig `json:"server"` +} + +type MongoDBOpsManagerStatus struct { + OpsManagerStatus OpsManagerStatus `json:"opsManager,omitempty"` + AppDbStatus AppDbStatus `json:"applicationDatabase,omitempty"` + BackupStatus BackupStatus `json:"backup,omitempty"` +} + +type OpsManagerStatus struct { + status.Common `json:",inline"` + Replicas int `json:"replicas,omitempty"` + Version string `json:"version,omitempty"` + Url string `json:"url,omitempty"` + Warnings []status.Warning `json:"warnings,omitempty"` +} + +type OpsManagerAgentVersionMapping []struct { + OpsManagerVersion string `json:"ops_manager_version"` + AgentVersion string `json:"agent_version"` +} + +// FindAgentVersionForOpsManager finds an agent version that corresponds to a version +// of Ops Manager passed as parameter. +func (m OpsManagerAgentVersionMapping) FindAgentVersionForOpsManager(omVersion string) string { + for _, v := range m { + if v.OpsManagerVersion == omVersion { + return v.AgentVersion + } + } + + return "" +} + +type AppDbStatus struct { + mdbv1.MongoDbStatus `json:",inline"` +} + +type BackupStatus struct { + status.Common `json:",inline"` + Version string `json:"version,omitempty"` + Warnings []status.Warning `json:"warnings,omitempty"` +} + +type FileSystemStoreConfig struct { + Name string `json:"name"` +} + +// DataStoreConfig is the description of the config used to reference to database. Reused by Oplog and Block stores +// Optionally references the user if the Mongodb is configured with authentication +type DataStoreConfig struct { + Name string `json:"name"` + MongoDBResourceRef userv1.MongoDBResourceRef `json:"mongodbResourceRef"` + MongoDBUserRef *MongoDBUserRef `json:"mongodbUserRef,omitempty"` + // Assignment Labels set in the Ops Manager + // +optional + AssignmentLabels []string `json:"assignmentLabels,omitempty"` +} + +func (f DataStoreConfig) Identifier() interface{} { + return f.Name +} + +type SecretRef struct { + Name string `json:"name"` +} + +type S3Config struct { + MongoDBResourceRef *userv1.MongoDBResourceRef `json:"mongodbResourceRef,omitempty"` + MongoDBUserRef *MongoDBUserRef `json:"mongodbUserRef,omitempty"` + S3SecretRef SecretRef `json:"s3SecretRef"` + Name string `json:"name"` + PathStyleAccessEnabled bool `json:"pathStyleAccessEnabled"` + S3BucketEndpoint string `json:"s3BucketEndpoint"` + S3BucketName string `json:"s3BucketName"` + // +optional + S3RegionOverride string `json:"s3RegionOverride"` + // Set this to "true" when you have custom certificates for your S3 buckets + // +optional + CustomCertificate bool `json:"customCertificate"` + // This is only set to "true" when user is running in EKS and is using AWS IRSA to configure + // S3 snapshot store. For more details refer this: https://aws.amazon.com/blogs/opensource/introducing-fine-grained-iam-roles-service-accounts/ + // +optional + IRSAEnabled bool `json:"irsaEnabled"` + // Assignment Labels set in the Ops Manager + // +optional + AssignmentLabels []string `json:"assignmentLabels"` +} + +func (s S3Config) Identifier() interface{} { + return s.Name +} + +// MongodbResourceObjectKey returns the "name-namespace" object key. Uses the AppDB name if the mongodb resource is not +// specified +func (s S3Config) MongodbResourceObjectKey(opsManager MongoDBOpsManager) client.ObjectKey { + ns := opsManager.Namespace + if s.MongoDBResourceRef == nil { + return client.ObjectKey{} + } + if s.MongoDBResourceRef.Namespace != "" { + ns = s.MongoDBResourceRef.Namespace + } + return client.ObjectKey{Name: s.MongoDBResourceRef.Name, Namespace: ns} +} + +func (s S3Config) MongodbUserObjectKey(defaultNamespace string) client.ObjectKey { + ns := defaultNamespace + if s.MongoDBResourceRef == nil { + return client.ObjectKey{} + } + if s.MongoDBResourceRef.Namespace != "" { + ns = s.MongoDBResourceRef.Namespace + } + return client.ObjectKey{Name: s.MongoDBUserRef.Name, Namespace: ns} +} + +// MongodbResourceObjectKey returns the object key for the mongodb resource referenced by the dataStoreConfig. +// It uses the "parent" object namespace if it is not overriden by 'MongoDBResourceRef.namespace' +func (f DataStoreConfig) MongodbResourceObjectKey(defaultNamespace string) client.ObjectKey { + ns := defaultNamespace + if f.MongoDBResourceRef.Namespace != "" { + ns = f.MongoDBResourceRef.Namespace + } + return client.ObjectKey{Name: f.MongoDBResourceRef.Name, Namespace: ns} +} + +func (f DataStoreConfig) MongodbUserObjectKey(defaultNamespace string) client.ObjectKey { + ns := defaultNamespace + if f.MongoDBResourceRef.Namespace != "" { + ns = f.MongoDBResourceRef.Namespace + } + return client.ObjectKey{Name: f.MongoDBUserRef.Name, Namespace: ns} +} + +type MongoDBUserRef struct { + Name string `json:"name"` +} + +func (m *MongoDBOpsManager) UnmarshalJSON(data []byte) error { + type MongoDBJSON *MongoDBOpsManager + if err := json.Unmarshal(data, (MongoDBJSON)(m)); err != nil { + return err + } + m.InitDefaultFields() + + return nil +} + +func (m *MongoDBOpsManager) InitDefaultFields() { + // providing backward compatibility for the deployments which didn't specify the 'replicas' before Operator 1.3.1 + // This doesn't update the object in Api server so the real spec won't change + // All newly created resources will pass through the normal validation so 'replicas' will never be 0 + if m.Spec.Replicas == 0 { + m.Spec.Replicas = 1 + } + + if m.Spec.Backup == nil { + m.Spec.Backup = newBackup() + } + + if m.Spec.Backup.Members == 0 { + m.Spec.Backup.Members = 1 + } + + m.Spec.AppDB.Security = ensureSecurityWithSCRAM(m.Spec.AppDB.Security) + + // setting ops manager name, namespace and ClusterDomain for the appdb (transient fields) + m.Spec.AppDB.OpsManagerName = m.Name + m.Spec.AppDB.Namespace = m.Namespace + m.Spec.AppDB.ClusterDomain = m.Spec.GetClusterDomain() + m.Spec.AppDB.ResourceType = mdbv1.ReplicaSet +} + +func ensureSecurityWithSCRAM(specSecurity *mdbv1.Security) *mdbv1.Security { + if specSecurity == nil { + specSecurity = &mdbv1.Security{TLSConfig: &mdbv1.TLSConfig{}} + } + // the only allowed authentication is SCRAM - it's implicit to the user and hidden from him + specSecurity.Authentication = &mdbv1.Authentication{Modes: []string{util.SCRAM}} + return specSecurity +} + +func (m *MongoDBOpsManager) SvcName() string { + return m.Name + "-svc" +} + +func (m *MongoDBOpsManager) AppDBMongoConnectionStringSecretName() string { + return m.Spec.AppDB.Name() + "-connection-string" +} + +func (m *MongoDBOpsManager) BackupServiceName() string { + return m.BackupStatefulSetName() + "-svc" +} + +func (ms *MongoDBOpsManagerSpec) BackupSvcPort() (int32, error) { + if port, ok := ms.Configuration[queryableBackupConfigPath]; ok { + val, err := strconv.Atoi(port) + if err != nil { + return -1, err + } + return int32(val), nil + } + return queryableBackupDefaultPort, nil +} + +func (m *MongoDBOpsManager) AddConfigIfDoesntExist(key, value string) bool { + if m.Spec.Configuration == nil { + m.Spec.Configuration = make(map[string]string) + } + if _, ok := m.Spec.Configuration[key]; !ok { + m.Spec.Configuration[key] = value + return true + } + return false +} + +func (m *MongoDBOpsManager) UpdateStatus(phase status.Phase, statusOptions ...status.Option) { + var statusPart status.Part + if option, exists := status.GetOption(statusOptions, status.OMPartOption{}); exists { + statusPart = option.(status.OMPartOption).StatusPart + } + + switch statusPart { + case status.AppDb: + m.updateStatusAppDb(phase, statusOptions...) + case status.OpsManager: + m.updateStatusOpsManager(phase, statusOptions...) + case status.Backup: + m.updateStatusBackup(phase, statusOptions...) + } +} + +func (m *MongoDBOpsManager) updateStatusAppDb(phase status.Phase, statusOptions ...status.Option) { + m.Status.AppDbStatus.UpdateCommonFields(phase, m.GetGeneration(), statusOptions...) + + if option, exists := status.GetOption(statusOptions, status.ReplicaSetMembersOption{}); exists { + m.Status.AppDbStatus.Members = option.(status.ReplicaSetMembersOption).Members + } + + if option, exists := status.GetOption(statusOptions, status.WarningsOption{}); exists { + m.Status.AppDbStatus.Warnings = append(m.Status.AppDbStatus.Warnings, option.(status.WarningsOption).Warnings...) + } + + if phase == status.PhaseRunning { + spec := m.Spec.AppDB + m.Status.AppDbStatus.Version = spec.GetMongoDBVersion() + m.Status.AppDbStatus.Message = "" + } +} + +func (m *MongoDBOpsManager) updateStatusOpsManager(phase status.Phase, statusOptions ...status.Option) { + m.Status.OpsManagerStatus.UpdateCommonFields(phase, m.GetGeneration(), statusOptions...) + + if option, exists := status.GetOption(statusOptions, status.BaseUrlOption{}); exists { + m.Status.OpsManagerStatus.Url = option.(status.BaseUrlOption).BaseUrl + } + + if option, exists := status.GetOption(statusOptions, status.WarningsOption{}); exists { + m.Status.OpsManagerStatus.Warnings = append(m.Status.OpsManagerStatus.Warnings, option.(status.WarningsOption).Warnings...) + } + + if phase == status.PhaseRunning { + m.Status.OpsManagerStatus.Replicas = m.Spec.Replicas + m.Status.OpsManagerStatus.Version = m.Spec.Version + m.Status.OpsManagerStatus.Message = "" + } +} + +func (m *MongoDBOpsManager) updateStatusBackup(phase status.Phase, statusOptions ...status.Option) { + m.Status.BackupStatus.UpdateCommonFields(phase, m.GetGeneration(), statusOptions...) + + if option, exists := status.GetOption(statusOptions, status.WarningsOption{}); exists { + m.Status.BackupStatus.Warnings = append(m.Status.BackupStatus.Warnings, option.(status.WarningsOption).Warnings...) + } + if phase == status.PhaseRunning { + m.Status.BackupStatus.Message = "" + m.Status.BackupStatus.Version = m.Spec.Version + } +} + +func (m *MongoDBOpsManager) SetWarnings(warnings []status.Warning, options ...status.Option) { + for _, part := range getPartsFromStatusOptions(options...) { + switch part { + case status.OpsManager: + m.Status.OpsManagerStatus.Warnings = warnings + case status.Backup: + m.Status.BackupStatus.Warnings = warnings + case status.AppDb: + m.Status.AppDbStatus.Warnings = warnings + } + } +} + +func (m *MongoDBOpsManager) AddOpsManagerWarningIfNotExists(warning status.Warning) { + m.Status.OpsManagerStatus.Warnings = status.Warnings(m.Status.OpsManagerStatus.Warnings).AddIfNotExists(warning) +} +func (m *MongoDBOpsManager) AddAppDBWarningIfNotExists(warning status.Warning) { + m.Status.AppDbStatus.Warnings = status.Warnings(m.Status.AppDbStatus.Warnings).AddIfNotExists(warning) +} +func (m *MongoDBOpsManager) AddBackupWarningIfNotExists(warning status.Warning) { + m.Status.BackupStatus.Warnings = status.Warnings(m.Status.BackupStatus.Warnings).AddIfNotExists(warning) +} + +func (m MongoDBOpsManager) GetPlural() string { + return "opsmanagers" +} + +func (m *MongoDBOpsManager) GetStatus(options ...status.Option) interface{} { + if part, exists := status.GetOption(options, status.OMPartOption{}); exists { + switch part.Value().(status.Part) { + case status.OpsManager: + return m.Status.OpsManagerStatus + case status.AppDb: + return m.Status.AppDbStatus + case status.Backup: + return m.Status.BackupStatus + } + } + return m.Status +} + +func (m MongoDBOpsManager) GetStatusPath(options ...status.Option) string { + if part, exists := status.GetOption(options, status.OMPartOption{}); exists { + switch part.Value().(status.Part) { + case status.OpsManager: + return "/status/opsManager" + case status.AppDb: + return "/status/applicationDatabase" + case status.Backup: + return "/status/backup" + } + } + // we should never actually reach this + return "/status" +} + +// APIKeySecretName returns the secret object name to store the API key to communicate to ops-manager. +// To ensure backward compatibility it checks if a secret key is present with the old format name({$ops-manager-name}-admin-key), +// if not it returns the new name format ({$ops-manager-namespace}-${ops-manager-name}-admin-key), to have multiple om deployments +// with the same name. +func (m *MongoDBOpsManager) APIKeySecretName(client secrets.SecretClientInterface, operatorSecretPath string) (string, error) { + oldAPISecretName := fmt.Sprintf("%s-admin-key", m.Name) + operatorNamespace := env.ReadOrPanic(util.CurrentNamespace) + oldAPIKeySecretNamespacedName := types.NamespacedName{Name: oldAPISecretName, Namespace: operatorNamespace} + + _, err := client.ReadSecret(oldAPIKeySecretNamespacedName, fmt.Sprintf("%s/%s/%s", operatorSecretPath, operatorNamespace, oldAPISecretName)) + if err != nil { + if secrets.SecretNotExist(err) { + return fmt.Sprintf("%s-%s-admin-key", m.Namespace, m.Name), nil + } + + return "", err + } + return oldAPISecretName, nil +} + +func (m *MongoDBOpsManager) GetSecurity() MongoDBOpsManagerSecurity { + if m.Spec.Security == nil { + return MongoDBOpsManagerSecurity{} + } + return *m.Spec.Security +} + +func (m *MongoDBOpsManager) BackupStatefulSetName() string { + return fmt.Sprintf("%s-backup-daemon", m.GetName()) +} + +func (m MongoDBOpsManager) GetSchemePort() (corev1.URIScheme, int) { + if m.IsTLSEnabled() { + return SchemePortFromAnnotation("https") + } + return SchemePortFromAnnotation("http") +} + +func (m MongoDBOpsManager) IsTLSEnabled() bool { + return m.Spec.Security != nil && (m.Spec.Security.TLS.SecretRef.Name != "" || m.Spec.Security.CertificatesSecretsPrefix != "") +} + +func (m MongoDBOpsManager) TLSCertificateSecretName() string { + // The old field has the precedence + if m.GetSecurity().TLS.SecretRef.Name != "" { + return m.GetSecurity().TLS.SecretRef.Name + } + if m.GetSecurity().CertificatesSecretsPrefix != "" { + return fmt.Sprintf("%s-%s-cert", m.GetSecurity().CertificatesSecretsPrefix, m.Name) + } + return "" +} + +func (m MongoDBOpsManager) CentralURL() string { + fqdn := dns.GetServiceFQDN(m.SvcName(), m.Namespace, m.Spec.GetClusterDomain()) + scheme, port := m.GetSchemePort() + + // TODO use url.URL to build the url + return fmt.Sprintf("%s://%s:%d", strings.ToLower(string(scheme)), fqdn, port) +} + +func (m MongoDBOpsManager) DesiredReplicas() int { + return m.Spec.AppDB.Members +} + +func (m MongoDBOpsManager) CurrentReplicas() int { + return m.Status.AppDbStatus.Members +} + +// MemberNames returns the *current* names of Application Database members +// Note, that it's wrong to rely on the status/spec here as the state in StatefulSet maybe different +func (m MongoDBOpsManager) AppDBMemberNames(currentMembersCount int) []string { + names := make([]string, currentMembersCount) + + for i := 0; i < currentMembersCount; i++ { + names[i] = fmt.Sprintf("%s-%d", m.Spec.AppDB.Name(), i) + } + return names +} + +func (m MongoDBOpsManager) BackupDaemonFQDNs() []string { + hostnames, _ := dns.GetDNSNames(m.BackupStatefulSetName(), m.BackupServiceName(), m.Namespace, m.Spec.GetClusterDomain(), m.Spec.Backup.Members, nil) + return hostnames +} + +func (m MongoDBOpsManager) NamespacedName() types.NamespacedName { + return types.NamespacedName{Name: m.Name, Namespace: m.Namespace} +} + +func (m MongoDBOpsManager) GetMongoDBVersionForAnnotation() string { + return m.Spec.AppDB.Version +} + +func (m MongoDBOpsManager) IsChangingVersion() bool { + prevVersion := m.getPreviousVersion() + return prevVersion != "" && prevVersion != m.Spec.AppDB.Version +} + +func (m MongoDBOpsManager) getPreviousVersion() string { + return annotations.GetAnnotation(&m, annotations.LastAppliedMongoDBVersion) +} + +// GetAppDBUpdateStrategyType returns the update strategy type the AppDB Statefulset needs to be configured with. +// This depends on whether a version change is in progress. +func (m MongoDBOpsManager) GetAppDBUpdateStrategyType() appsv1.StatefulSetUpdateStrategyType { + if !m.IsChangingVersion() { + return appsv1.RollingUpdateStatefulSetStrategyType + } + return appsv1.OnDeleteStatefulSetStrategyType +} + +// GetSecretsMountedIntoPod returns the list of strings mounted into the pod that we need to watch. +func (m MongoDBOpsManager) GetSecretsMountedIntoPod() []string { + secrets := []string{} + tls := m.TLSCertificateSecretName() + if tls != "" { + secrets = append(secrets, tls) + } + + if m.Spec.AdminSecret != "" { + secrets = append(secrets, m.Spec.AdminSecret) + } + + if m.Spec.Backup != nil { + for _, config := range m.Spec.Backup.S3Configs { + if config.S3SecretRef.Name != "" { + secrets = append(secrets, config.S3SecretRef.Name) + } + } + } + + return secrets +} + +// newBackup returns an empty backup object +func newBackup() *MongoDBOpsManagerBackup { + return &MongoDBOpsManagerBackup{Enabled: true} +} + +// ConvertToEnvVarFormat takes a property in the form of +// mms.mail.transport, and converts it into the expected env var format of +// OM_PROP_mms_mail_transport +func ConvertNameToEnvVarFormat(propertyFormat string) string { + withPrefix := fmt.Sprintf("%s%s", util.OmPropertyPrefix, propertyFormat) + return strings.Replace(withPrefix, ".", "_", -1) +} + +func SchemePortFromAnnotation(annotation string) (corev1.URIScheme, int) { + scheme := corev1.URISchemeHTTP + port := util.OpsManagerDefaultPortHTTP + if strings.ToUpper(annotation) == "HTTPS" { + scheme = corev1.URISchemeHTTPS + port = util.OpsManagerDefaultPortHTTPS + } + + return scheme, port +} + +func getPartsFromStatusOptions(options ...status.Option) []status.Part { + var parts []status.Part + for _, part := range options { + if omPart, ok := part.(status.OMPartOption); ok { + statusPart := omPart.Value().(status.Part) + parts = append(parts, statusPart) + } + } + return parts +} + +// AppDBConfigurable holds information needed to enable SCRAM-SHA +// and combines AppDBSpec (includes SCRAM configuration) +// and MongoDBOpsManager instance (used as the owner reference for the SCRAM related resources) +type AppDBConfigurable struct { + AppDBSpec + OpsManager MongoDBOpsManager +} + +// GetOwnerReferences returns the OwnerReferences pointing to the MongoDBOpsManager instance and used by SCRAM related resources. +func (m AppDBConfigurable) GetOwnerReferences() []metav1.OwnerReference { + groupVersionKind := schema.GroupVersionKind{ + Group: GroupVersion.Group, + Version: GroupVersion.Version, + Kind: m.OpsManager.Kind, + } + ownerReference := *metav1.NewControllerRef(&m.OpsManager, groupVersionKind) + return []metav1.OwnerReference{ownerReference} +} diff --git a/api/v1/om/opsmanager_types_test.go b/api/v1/om/opsmanager_types_test.go new file mode 100644 index 000000000..5313ade74 --- /dev/null +++ b/api/v1/om/opsmanager_types_test.go @@ -0,0 +1,173 @@ +package om + +import ( + "testing" + + "github.com/10gen/ops-manager-kubernetes/api/v1/status" + "github.com/stretchr/testify/assert" +) + +func TestMongoDBOpsManager_AddWarningIfNotExists(t *testing.T) { + resource := &MongoDBOpsManager{} + resource.AddOpsManagerWarningIfNotExists("my test warning") + resource.AddOpsManagerWarningIfNotExists("my test warning") + resource.AddOpsManagerWarningIfNotExists("my other test warning") + assert.Equal(t, []status.Warning{"my test warning;", "my other test warning"}, resource.Status.OpsManagerStatus.Warnings) + assert.Empty(t, resource.Status.AppDbStatus.Warnings) + assert.Empty(t, resource.Status.BackupStatus.Warnings) +} + +func TestAppDB_AddWarningIfNotExists(t *testing.T) { + resource := &MongoDBOpsManager{} + resource.AddAppDBWarningIfNotExists("my test warning") + resource.AddAppDBWarningIfNotExists("my test warning") + resource.AddAppDBWarningIfNotExists("my other test warning") + assert.Equal(t, []status.Warning{"my test warning;", "my other test warning"}, resource.Status.AppDbStatus.Warnings) + assert.Empty(t, resource.Status.BackupStatus.Warnings) + assert.Empty(t, resource.Status.OpsManagerStatus.Warnings) +} + +func TestBackup_AddWarningIfNotExists(t *testing.T) { + resource := &MongoDBOpsManager{} + resource.AddBackupWarningIfNotExists("my test warning") + resource.AddBackupWarningIfNotExists("my test warning") + resource.AddBackupWarningIfNotExists("my other test warning") + assert.Equal(t, []status.Warning{"my test warning;", "my other test warning"}, resource.Status.BackupStatus.Warnings) + assert.Empty(t, resource.Status.AppDbStatus.Warnings) + assert.Empty(t, resource.Status.OpsManagerStatus.Warnings) +} + +func TestGetPartsFromStatusOptions(t *testing.T) { + + t.Run("Empty list returns nil slice", func(t *testing.T) { + assert.Nil(t, getPartsFromStatusOptions()) + }) + + t.Run("Ops Manager parts are extracted correctly", func(t *testing.T) { + statusOptions := []status.Option{ + status.NewBackupStatusOption("some-status"), + status.NewOMPartOption(status.OpsManager), + status.NewOMPartOption(status.Backup), + status.NewOMPartOption(status.AppDb), + status.NewBaseUrlOption("base-url"), + } + res := getPartsFromStatusOptions(statusOptions...) + assert.Len(t, res, 3) + assert.Equal(t, status.OpsManager, res[0]) + assert.Equal(t, status.Backup, res[1]) + assert.Equal(t, status.AppDb, res[2]) + }) +} + +func TestTLSCertificateSecretName(t *testing.T) { + om := NewOpsManagerBuilderDefault().Build() + om.SetName("new-manager") + tests := []struct { + name string + security MongoDBOpsManagerSecurity + expected string + }{ + { + name: "TLS Certificate Secret name empty", + security: MongoDBOpsManagerSecurity{}, + expected: "", + }, + { + name: "TLS Certificate Secret name from TLS.SecretRef.Name", + security: MongoDBOpsManagerSecurity{ + TLS: MongoDBOpsManagerTLS{ + SecretRef: TLSSecretRef{ + Name: "ops-manager-cert", + }, + }, + }, + expected: "ops-manager-cert", + }, + { + name: "TLS Certificate Secret name from Security.CertificatesSecretPrefix", + security: MongoDBOpsManagerSecurity{ + CertificatesSecretsPrefix: "om", + }, + expected: "om-new-manager-cert", + }, + { + name: "TLS Certificate Secret name from TLS.SecretRef.Name has priority", + security: MongoDBOpsManagerSecurity{ + TLS: MongoDBOpsManagerTLS{ + SecretRef: TLSSecretRef{ + Name: "ops-manager-cert", + }, + }, + CertificatesSecretsPrefix: "prefix", + }, + expected: "ops-manager-cert", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + om.Spec.Security = &tc.security + assert.Equal(t, tc.expected, om.TLSCertificateSecretName()) + }) + } +} + +func TestIsTLSEnabled(t *testing.T) { + om := NewOpsManagerBuilderDefault().Build() + tests := []struct { + name string + security *MongoDBOpsManagerSecurity + expected bool + }{ + { + name: "TLS is not enabled when security is not specified", + security: nil, + expected: false, + }, + { + name: "TLS is not enabled when TLS.SecretRef.Name is not specified", + security: &MongoDBOpsManagerSecurity{ + TLS: MongoDBOpsManagerTLS{ + SecretRef: TLSSecretRef{}, + }, + }, + expected: false, + }, + { + name: "TLS is enabled when TLS.SecretRef.Name is specified", + security: &MongoDBOpsManagerSecurity{ + TLS: MongoDBOpsManagerTLS{ + SecretRef: TLSSecretRef{ + Name: "ops-manager-cert", + }, + }, + }, + expected: true, + }, + { + name: "TLS is enabled when CertificatesSecretsPrefix is specified", + security: &MongoDBOpsManagerSecurity{ + CertificatesSecretsPrefix: "prefix", + }, + expected: true, + }, + { + name: "TLS is enabled when both sources of cert secret name are specified", + security: &MongoDBOpsManagerSecurity{ + TLS: MongoDBOpsManagerTLS{ + SecretRef: TLSSecretRef{ + Name: "ops-manager-cert", + }, + }, + CertificatesSecretsPrefix: "prefix", + }, + expected: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + om.Spec.Security = tc.security + assert.Equal(t, tc.expected, om.IsTLSEnabled()) + }) + } +} diff --git a/api/v1/om/opsmanager_validation.go b/api/v1/om/opsmanager_validation.go new file mode 100644 index 000000000..296ab2f01 --- /dev/null +++ b/api/v1/om/opsmanager_validation.go @@ -0,0 +1,190 @@ +package om + +import ( + "errors" + "fmt" + "net" + + "github.com/blang/semver" + + v1 "github.com/10gen/ops-manager-kubernetes/api/v1" + "github.com/10gen/ops-manager-kubernetes/api/v1/status" + + "github.com/10gen/ops-manager-kubernetes/pkg/util/versionutil" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook" +) + +// IMPORTANT: this package is intended to contain only "simple" validation—in +// other words, validation that is based only on the properties in the Ops Manager +// resource. More complex validation, such as validation that needs to observe +// the state of the cluster, belongs somewhere else. + +var _ webhook.Validator = &MongoDBOpsManager{} + +// ValidateCreate and ValidateUpdate should be the same if we intend to do this +// on every reconciliation as well +func (m *MongoDBOpsManager) ValidateCreate() error { + return m.ProcessValidationsWebhook() +} + +func (m *MongoDBOpsManager) ValidateUpdate(old runtime.Object) error { + return m.ProcessValidationsWebhook() +} + +// ValidateDelete does nothing as we assume validation on deletion is +// unnecessary +func (m *MongoDBOpsManager) ValidateDelete() error { + return nil +} + +func errorNotConfigurableForAppDB(field string) v1.ValidationResult { + return v1.OpsManagerResourceValidationError(fmt.Sprintf("%s field is not configurable for application databases", field), status.AppDb) +} + +func validOmVersion(os MongoDBOpsManagerSpec) v1.ValidationResult { + _, err := versionutil.StringToSemverVersion(os.Version) + if err != nil { + return v1.OpsManagerResourceValidationError(fmt.Sprintf("'%s' is an invalid value for spec.version: %s", os.Version, err), status.OpsManager) + } + return v1.ValidationSuccess() +} + +func validAppDBVersion(os MongoDBOpsManagerSpec) v1.ValidationResult { + version := os.AppDB.GetMongoDBVersion() + v, err := semver.Make(version) + if err != nil { + return v1.OpsManagerResourceValidationError(fmt.Sprintf("'%s' is an invalid value for spec.applicationDatabase.version: %s", version, err), status.AppDb) + } + fourZero, _ := semver.Make("4.0.0") + if v.LT(fourZero) { + return v1.OpsManagerResourceValidationError("the version of Application Database must be >= 4.0", status.AppDb) + } + + return v1.ValidationSuccess() +} + +func connectivityIsNotConfigurable(os MongoDBOpsManagerSpec) v1.ValidationResult { + if os.AppDB.Connectivity != nil { + return errorNotConfigurableForAppDB("connectivity") + } + return v1.ValidationSuccess() +} + +// ConnectionSpec fields +func credentialsIsNotConfigurable(os MongoDBOpsManagerSpec) v1.ValidationResult { + if os.AppDB.Credentials != "" { + return errorNotConfigurableForAppDB("credentials") + } + return v1.ValidationSuccess() +} + +func opsManagerConfigIsNotConfigurable(os MongoDBOpsManagerSpec) v1.ValidationResult { + if os.AppDB.OpsManagerConfig != nil { + return errorNotConfigurableForAppDB("opsManager") + } + return v1.ValidationSuccess() +} + +func cloudManagerConfigIsNotConfigurable(os MongoDBOpsManagerSpec) v1.ValidationResult { + if os.AppDB.CloudManagerConfig != nil { + return errorNotConfigurableForAppDB("cloudManager") + } + return v1.ValidationSuccess() +} + +// onlyFileSystemStoreIsEnabled checks if only FileSystemSnapshotStore is configured and not S3Store/Blockstore +func onlyFileSystemStoreIsEnabled(bp MongoDBOpsManagerBackup) bool { + if len(bp.BlockStoreConfigs) == 0 && len(bp.S3Configs) == 0 && len(bp.FileSystemStoreConfigs) > 0 { + return true + } + return false +} + +// s3StoreMongodbUserSpecifiedNoMongoResource checks that 'mongodbResourceRef' is provided if 'mongodbUserRef' is configured +func s3StoreMongodbUserSpecifiedNoMongoResource(os MongoDBOpsManagerSpec) v1.ValidationResult { + if !os.Backup.Enabled || len(os.Backup.S3Configs) == 0 { + return v1.ValidationSuccess() + } + + if onlyFileSystemStoreIsEnabled(*os.Backup) { + return v1.ValidationSuccess() + } + + for _, config := range os.Backup.S3Configs { + if config.MongoDBUserRef != nil && config.MongoDBResourceRef == nil { + return v1.OpsManagerResourceValidationError( + fmt.Sprintf("'mongodbResourceRef' must be specified if 'mongodbUserRef' is configured (S3 Store: %s)", config.Name), status.OpsManager, + ) + } + } + return v1.ValidationSuccess() +} + +func kmipValidation(os MongoDBOpsManagerSpec) v1.ValidationResult { + if os.Backup == nil || os.Backup.Enabled == false || os.Backup.Encryption == nil || os.Backup.Encryption.Kmip == nil { + return v1.ValidationSuccess() + } + + if _, _, err := net.SplitHostPort(os.Backup.Encryption.Kmip.Server.URL); err != nil { + return v1.OpsManagerResourceValidationError(fmt.Sprintf("kmip url can not be splitted into host and port, see %v", err), status.OpsManager) + } + + if len(os.Backup.Encryption.Kmip.Server.CA) == 0 { + return v1.OpsManagerResourceValidationError("kmip CA ConfigMap name can not be empty", status.OpsManager) + } + + return v1.ValidationSuccess() +} + +func (om MongoDBOpsManager) RunValidations() []v1.ValidationResult { + validators := []func(m MongoDBOpsManagerSpec) v1.ValidationResult{ + validOmVersion, + validAppDBVersion, + connectivityIsNotConfigurable, + cloudManagerConfigIsNotConfigurable, + opsManagerConfigIsNotConfigurable, + credentialsIsNotConfigurable, + s3StoreMongodbUserSpecifiedNoMongoResource, + kmipValidation, + } + var validationResults []v1.ValidationResult + + for _, validator := range validators { + res := validator(om.Spec) + if res.Level > 0 { + validationResults = append(validationResults, res) + } + } + + return validationResults +} + +func (m *MongoDBOpsManager) ProcessValidationsWebhook() error { + for _, res := range m.RunValidations() { + if res.Level == v1.ErrorLevel { + return errors.New(res.Msg) + } + } + return nil +} +func (m *MongoDBOpsManager) ProcessValidationsOnReconcile() (error, status.Part) { + for _, res := range m.RunValidations() { + if res.Level == v1.ErrorLevel { + return errors.New(res.Msg), res.OmStatusPart + } + + if res.Level == v1.WarningLevel { + switch res.OmStatusPart { + case status.OpsManager: + m.AddOpsManagerWarningIfNotExists(status.Warning(res.Msg)) + case status.AppDb: + m.AddAppDBWarningIfNotExists(status.Warning(res.Msg)) + case status.Backup: + m.AddBackupWarningIfNotExists(status.Warning(res.Msg)) + } + } + } + + return nil, status.None +} diff --git a/api/v1/om/opsmanager_validation_test.go b/api/v1/om/opsmanager_validation_test.go new file mode 100644 index 000000000..c3e77f025 --- /dev/null +++ b/api/v1/om/opsmanager_validation_test.go @@ -0,0 +1,229 @@ +package om + +import ( + "testing" + + v1 "github.com/10gen/ops-manager-kubernetes/api/v1" + + "github.com/10gen/ops-manager-kubernetes/api/v1/status" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/pkg/util/versionutil" + "github.com/stretchr/testify/assert" +) + +func TestOpsManagerValidation(t *testing.T) { + type args struct { + testedOm MongoDBOpsManager + expectedPart status.Part + expectedError bool + expectedErrorMessage string + } + tests := map[string]args{ + "Valid KMIP configuration": { + testedOm: NewOpsManagerBuilderDefault().SetBackup(MongoDBOpsManagerBackup{ + Enabled: true, + Encryption: &Encryption{ + Kmip: &KmipConfig{ + Server: v1.KmipServerConfig{ + CA: "kmip-ca", + URL: "kmip.mongodb.com:5696", + }, + }, + }, + }).Build(), + expectedError: false, + expectedPart: status.None, + }, + "Valid disabled KMIP configuration": { + testedOm: NewOpsManagerBuilderDefault().SetBackup(MongoDBOpsManagerBackup{ + Enabled: false, + Encryption: &Encryption{ + Kmip: &KmipConfig{ + Server: v1.KmipServerConfig{ + URL: "::this::is::a::wrong::address", + }, + }, + }, + }).Build(), + expectedError: false, + expectedPart: status.None, + }, + "Invalid KMIP configuration with wrong url": { + testedOm: NewOpsManagerBuilderDefault().SetBackup(MongoDBOpsManagerBackup{ + Enabled: true, + Encryption: &Encryption{ + Kmip: &KmipConfig{ + Server: v1.KmipServerConfig{ + CA: "kmip-ca", + URL: "wrong:::url:::123", + }, + }, + }, + }).Build(), + expectedError: true, + expectedPart: status.OpsManager, + }, + "Invalid KMIP configuration url and no port": { + testedOm: NewOpsManagerBuilderDefault().SetBackup(MongoDBOpsManagerBackup{ + Enabled: true, + Encryption: &Encryption{ + Kmip: &KmipConfig{ + Server: v1.KmipServerConfig{ + CA: "kmip-ca", + URL: "localhost", + }, + }, + }, + }).Build(), + expectedError: true, + expectedPart: status.OpsManager, + }, + "Invalid KMIP configuration without CA": { + testedOm: NewOpsManagerBuilderDefault().SetBackup(MongoDBOpsManagerBackup{ + Enabled: true, + Encryption: &Encryption{ + Kmip: &KmipConfig{ + Server: v1.KmipServerConfig{ + URL: "kmip.mongodb.com:5696", + }, + }, + }, + }).Build(), + expectedError: true, + expectedPart: status.OpsManager, + }, + "Valid default OpsManager": { + testedOm: NewOpsManagerBuilderDefault().Build(), + expectedError: false, + expectedPart: status.None, + }, + "Invalid AppDB connectivity spec": { + testedOm: NewOpsManagerBuilderDefault(). + SetAppDbConnectivity(mdbv1.MongoDBConnectivity{ReplicaSetHorizons: []mdbv1.MongoDBHorizonConfig{}}). + Build(), + expectedError: true, + expectedErrorMessage: "connectivity field is not configurable for application databases", + expectedPart: status.AppDb, + }, + "Invalid AppDB credentials": { + testedOm: NewOpsManagerBuilderDefault(). + SetAppDbCredentials("invalid"). + Build(), + expectedError: true, + expectedErrorMessage: "credentials field is not configurable for application databases", + expectedPart: status.AppDb, + }, + "Invalid AppDB OpsManager config": { + testedOm: NewOpsManagerBuilderDefault(). + SetOpsManagerConfig(mdbv1.PrivateCloudConfig{}). + Build(), + expectedError: true, + expectedErrorMessage: "opsManager field is not configurable for application databases", + expectedPart: status.AppDb, + }, + "Invalid AppDB CloudManager config": { + testedOm: NewOpsManagerBuilderDefault(). + SetCloudManagerConfig(mdbv1.PrivateCloudConfig{}). + Build(), + expectedError: true, + expectedErrorMessage: "cloudManager field is not configurable for application databases", + expectedPart: status.AppDb, + }, + "Invalid S3 Store config": { + testedOm: NewOpsManagerBuilderDefault(). + AddS3SnapshotStore(S3Config{Name: "test", MongoDBUserRef: &MongoDBUserRef{Name: "foo"}}). + Build(), + expectedError: true, + expectedErrorMessage: "'mongodbResourceRef' must be specified if 'mongodbUserRef' is configured (S3 Store: test)", + expectedPart: status.OpsManager, + }, + "Invalid OpsManager version": { + testedOm: NewOpsManagerBuilderDefault(). + SetVersion("4.4"). + Build(), + expectedError: true, + expectedErrorMessage: "'4.4' is an invalid value for spec.version: Ops Manager Status spec.version 4.4 is invalid", + expectedPart: status.OpsManager, + }, + "Invalid foo OpsManager version": { + testedOm: NewOpsManagerBuilderDefault(). + SetVersion("foo"). + Build(), + expectedError: true, + expectedErrorMessage: "'foo' is an invalid value for spec.version: Ops Manager Status spec.version foo is invalid", + expectedPart: status.OpsManager, + }, + "Invalid 4_4.4.0 OpsManager version": { + testedOm: NewOpsManagerBuilderDefault(). + SetVersion("4_4.4.0"). + Build(), + expectedError: true, + expectedErrorMessage: "'4_4.4.0' is an invalid value for spec.version: Ops Manager Status spec.version 4_4.4.0 is invalid", + expectedPart: status.OpsManager, + }, + "Invalid 4.4_4.0 OpsManager version": { + testedOm: NewOpsManagerBuilderDefault(). + SetVersion("4.4_4.0"). + Build(), + expectedError: true, + expectedErrorMessage: "'4.4_4.0' is an invalid value for spec.version: Ops Manager Status spec.version 4.4_4.0 is invalid", + expectedPart: status.OpsManager, + }, + "Invalid 4.4.0_0 OpsManager version": { + testedOm: NewOpsManagerBuilderDefault(). + SetVersion("4.4.0_0"). + Build(), + expectedError: true, + expectedErrorMessage: "'4.4.0_0' is an invalid value for spec.version: Ops Manager Status spec.version 4.4.0_0 is invalid", + expectedPart: status.OpsManager, + }, + "Too low AppDB version": { + testedOm: NewOpsManagerBuilderDefault(). + SetAppDbVersion("3.6.12"). + Build(), + expectedError: true, + expectedErrorMessage: "the version of Application Database must be >= 4.0", + expectedPart: status.AppDb, + }, + "Valid 4.0.0 OpsManager version": { + testedOm: NewOpsManagerBuilderDefault().SetVersion("4.0.0").Build(), + expectedError: false, + expectedPart: status.None, + }, + "Valid 4.2.0-rc1 OpsManager version": { + testedOm: NewOpsManagerBuilderDefault().SetVersion("4.2.0-rc1").Build(), + expectedError: false, + expectedPart: status.None, + }, + "Valid 4.5.0-ent OpsManager version": { + testedOm: NewOpsManagerBuilderDefault().SetVersion("4.5.0-ent").Build(), + expectedError: false, + expectedPart: status.None, + }, + } + for testName := range tests { + t.Run(testName, func(t *testing.T) { + testConfig := tests[testName] + err, part := testConfig.testedOm.ProcessValidationsOnReconcile() + assert.Equal(t, testConfig.expectedError, err != nil) + assert.Equal(t, testConfig.expectedPart, part) + if len(testConfig.expectedErrorMessage) != 0 { + assert.Equal(t, testConfig.expectedErrorMessage, err.Error()) + } + }) + } +} + +func TestOpsManager_RunValidations_InvalidPrerelease(t *testing.T) { + om := NewOpsManagerBuilder().SetVersion("3.5.0-1193-x86_64").SetAppDbVersion("4.4.4-ent").Build() + version, err := versionutil.StringToSemverVersion(om.Spec.Version) + assert.NoError(t, err) + + err, part := om.ProcessValidationsOnReconcile() + assert.Equal(t, status.None, part) + assert.NoError(t, err) + assert.Equal(t, uint64(3), version.Major) + assert.Equal(t, uint64(5), version.Minor) + assert.Equal(t, uint64(0), version.Patch) +} diff --git a/api/v1/om/opsmanagerbuilder.go b/api/v1/om/opsmanagerbuilder.go new file mode 100644 index 000000000..e27ada9bb --- /dev/null +++ b/api/v1/om/opsmanagerbuilder.go @@ -0,0 +1,219 @@ +package om + +import ( + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + userv1 "github.com/10gen/ops-manager-kubernetes/api/v1/user" + mdbc "github.com/mongodb/mongodb-kubernetes-operator/api/v1" + appsv1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/types" +) + +type OpsManagerBuilder struct { + om MongoDBOpsManager +} + +func NewOpsManagerBuilder() *OpsManagerBuilder { + return &OpsManagerBuilder{} +} + +func NewOpsManagerBuilderDefault() *OpsManagerBuilder { + return NewOpsManagerBuilder().SetVersion("4.4.1").SetAppDbMembers(3).SetAppDbPodSpec(*DefaultAppDbBuilder().Build().PodSpec).SetAppDbVersion("4.2.20") +} + +func NewOpsManagerBuilderFromResource(resource MongoDBOpsManager) *OpsManagerBuilder { + return &OpsManagerBuilder{om: resource} +} + +func (b *OpsManagerBuilder) SetVersion(version string) *OpsManagerBuilder { + b.om.Spec.Version = version + return b +} + +func (b *OpsManagerBuilder) SetAppDbVersion(version string) *OpsManagerBuilder { + b.om.Spec.AppDB.Version = version + return b +} + +func (b *OpsManagerBuilder) SetAppDbPodSpec(podSpec mdbv1.MongoDbPodSpec) *OpsManagerBuilder { + b.om.Spec.AppDB.PodSpec = &podSpec + return b +} + +func (b *OpsManagerBuilder) SetOpsManagerConfig(config mdbv1.PrivateCloudConfig) *OpsManagerBuilder { + b.om.Spec.AppDB.OpsManagerConfig = &config + return b +} + +func (b *OpsManagerBuilder) SetCloudManagerConfig(config mdbv1.PrivateCloudConfig) *OpsManagerBuilder { + b.om.Spec.AppDB.CloudManagerConfig = &config + return b +} + +func (b *OpsManagerBuilder) SetAppDbConnectivity(connectivitySpec mdbv1.MongoDBConnectivity) *OpsManagerBuilder { + b.om.Spec.AppDB.Connectivity = &connectivitySpec + return b +} + +func (b *OpsManagerBuilder) SetAppDBTLSConfig(config mdbv1.TLSConfig) *OpsManagerBuilder { + if b.om.Spec.AppDB.Security == nil { + b.om.Spec.AppDB.Security = &mdbv1.Security{} + } + + b.om.Spec.AppDB.Security.TLSConfig = &config + return b +} + +func (b *OpsManagerBuilder) SetTLSConfig(config MongoDBOpsManagerTLS) *OpsManagerBuilder { + if b.om.Spec.Security == nil { + b.om.Spec.Security = &MongoDBOpsManagerSecurity{} + } + + b.om.Spec.Security.TLS = config + return b +} + +func (b *OpsManagerBuilder) AddS3Config(s3ConfigName, credentialsName string) *OpsManagerBuilder { + if b.om.Spec.Backup == nil { + b.om.Spec.Backup = &MongoDBOpsManagerBackup{Enabled: true} + } + if b.om.Spec.Backup.S3Configs == nil { + b.om.Spec.Backup.S3Configs = []S3Config{} + } + b.om.Spec.Backup.S3Configs = append(b.om.Spec.Backup.S3Configs, S3Config{ + S3SecretRef: SecretRef{ + Name: credentialsName, + }, + Name: s3ConfigName, + }) + return b +} + +func (b *OpsManagerBuilder) AddOplogStoreConfig(oplogStoreName, userName string, mdbNsName types.NamespacedName) *OpsManagerBuilder { + if b.om.Spec.Backup == nil { + b.om.Spec.Backup = &MongoDBOpsManagerBackup{Enabled: true} + } + if b.om.Spec.Backup.OplogStoreConfigs == nil { + b.om.Spec.Backup.OplogStoreConfigs = []DataStoreConfig{} + } + b.om.Spec.Backup.OplogStoreConfigs = append(b.om.Spec.Backup.OplogStoreConfigs, DataStoreConfig{ + Name: oplogStoreName, + MongoDBResourceRef: userv1.MongoDBResourceRef{ + Name: mdbNsName.Name, + Namespace: mdbNsName.Namespace, + }, + MongoDBUserRef: &MongoDBUserRef{ + Name: userName, + }, + }) + return b +} + +func (b *OpsManagerBuilder) AddBlockStoreConfig(blockStoreName, userName string, mdbNsName types.NamespacedName) *OpsManagerBuilder { + if b.om.Spec.Backup == nil { + b.om.Spec.Backup = &MongoDBOpsManagerBackup{Enabled: true} + } + if b.om.Spec.Backup.BlockStoreConfigs == nil { + b.om.Spec.Backup.BlockStoreConfigs = []DataStoreConfig{} + } + b.om.Spec.Backup.BlockStoreConfigs = append(b.om.Spec.Backup.BlockStoreConfigs, DataStoreConfig{ + Name: blockStoreName, + MongoDBResourceRef: userv1.MongoDBResourceRef{ + Name: mdbNsName.Name, + Namespace: mdbNsName.Namespace, + }, + MongoDBUserRef: &MongoDBUserRef{ + Name: userName, + }, + }) + return b +} + +func (b *OpsManagerBuilder) SetClusterDomain(clusterDomain string) *OpsManagerBuilder { + b.om.Spec.ClusterDomain = clusterDomain + return b +} + +func (b *OpsManagerBuilder) SetName(name string) *OpsManagerBuilder { + b.om.Name = name + return b +} + +func (b *OpsManagerBuilder) SetAppDbMembers(members int) *OpsManagerBuilder { + b.om.Spec.AppDB.Members = members + return b +} + +func (b *OpsManagerBuilder) SetAppDbCredentials(credentials string) *OpsManagerBuilder { + b.om.Spec.AppDB.Credentials = credentials + return b +} + +func (b *OpsManagerBuilder) SetBackupMembers(members int) *OpsManagerBuilder { + if b.om.Spec.Backup == nil { + b.om.Spec.Backup = &MongoDBOpsManagerBackup{Enabled: true} + } + b.om.Spec.Backup.Members = members + return b +} + +func (b *OpsManagerBuilder) SetAdditionalMongodbConfig(config *mdbv1.AdditionalMongodConfig) *OpsManagerBuilder { + b.om.Spec.AppDB.AdditionalMongodConfig = config + return b +} + +func (b *OpsManagerBuilder) SetAppDbFeatureCompatibility(version string) *OpsManagerBuilder { + b.om.Spec.AppDB.FeatureCompatibilityVersion = &version + return b +} + +func (b *OpsManagerBuilder) SetStatefulSetSpec(customSpec appsv1.StatefulSetSpec) *OpsManagerBuilder { + b.om.Spec.StatefulSetConfiguration = &mdbc.StatefulSetConfiguration{SpecWrapper: mdbc.StatefulSetSpecWrapper{Spec: customSpec}} + return b +} + +func (b *OpsManagerBuilder) SetAppDBPassword(secretName, key string) *OpsManagerBuilder { + b.om.Spec.AppDB.PasswordSecretKeyRef = &userv1.SecretKeyRef{Name: secretName, Key: key} + return b +} + +func (b *OpsManagerBuilder) SetAppDBAutomationConfigOverride(acOverride mdbc.AutomationConfigOverride) *OpsManagerBuilder { + b.om.Spec.AppDB.AutomationConfigOverride = &acOverride + return b +} + +func (b *OpsManagerBuilder) SetBackup(backup MongoDBOpsManagerBackup) *OpsManagerBuilder { + b.om.Spec.Backup = &backup + return b +} + +func (b *OpsManagerBuilder) AddConfiguration(key, value string) *OpsManagerBuilder { + b.om.AddConfigIfDoesntExist(key, value) + return b +} + +func (b *OpsManagerBuilder) AddS3SnapshotStore(config S3Config) *OpsManagerBuilder { + if b.om.Spec.Backup == nil { + b.om.Spec.Backup = newBackup() + } + if b.om.Spec.Backup.S3Configs == nil { + b.om.Spec.Backup.S3Configs = []S3Config{} + } + b.om.Spec.Backup.S3Configs = append(b.om.Spec.Backup.S3Configs, config) + return b +} + +func (b *OpsManagerBuilder) SetOMStatusVersion(version string) *OpsManagerBuilder { + b.om.Status.OpsManagerStatus.Version = version + return b +} + +func (b *OpsManagerBuilder) SetExternalConnectivity(externalConnectivity MongoDBOpsManagerServiceDefinition) *OpsManagerBuilder { + b.om.Spec.MongoDBOpsManagerExternalConnectivity = &externalConnectivity + return b +} +func (b *OpsManagerBuilder) Build() MongoDBOpsManager { + b.om.InitDefaultFields() + return *b.om.DeepCopy() +} + +// ************************* Private methods ************************************ diff --git a/api/v1/om/zz_generated.deepcopy.go b/api/v1/om/zz_generated.deepcopy.go new file mode 100644 index 000000000..788c4633a --- /dev/null +++ b/api/v1/om/zz_generated.deepcopy.go @@ -0,0 +1,652 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright 2021. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package om + +import ( + "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/api/v1/status" + "github.com/10gen/ops-manager-kubernetes/api/v1/user" + v1 "github.com/mongodb/mongodb-kubernetes-operator/api/v1" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/automationconfig" + "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AppDBConfigurable) DeepCopyInto(out *AppDBConfigurable) { + *out = *in + in.AppDBSpec.DeepCopyInto(&out.AppDBSpec) + in.OpsManager.DeepCopyInto(&out.OpsManager) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppDBConfigurable. +func (in *AppDBConfigurable) DeepCopy() *AppDBConfigurable { + if in == nil { + return nil + } + out := new(AppDBConfigurable) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AppDBSpec) DeepCopyInto(out *AppDBSpec) { + *out = *in + if in.PodSpec != nil { + in, out := &in.PodSpec, &out.PodSpec + *out = new(mdb.MongoDbPodSpec) + (*in).DeepCopyInto(*out) + } + if in.FeatureCompatibilityVersion != nil { + in, out := &in.FeatureCompatibilityVersion, &out.FeatureCompatibilityVersion + *out = new(string) + **out = **in + } + if in.Security != nil { + in, out := &in.Security, &out.Security + *out = new(mdb.Security) + (*in).DeepCopyInto(*out) + } + if in.Connectivity != nil { + in, out := &in.Connectivity, &out.Connectivity + *out = new(mdb.MongoDBConnectivity) + (*in).DeepCopyInto(*out) + } + if in.AdditionalMongodConfig != nil { + in, out := &in.AdditionalMongodConfig, &out.AdditionalMongodConfig + *out = (*in).DeepCopy() + } + in.AutomationAgent.DeepCopyInto(&out.AutomationAgent) + in.MonitoringAgent.DeepCopyInto(&out.MonitoringAgent) + in.ConnectionSpec.DeepCopyInto(&out.ConnectionSpec) + if in.PasswordSecretKeyRef != nil { + in, out := &in.PasswordSecretKeyRef, &out.PasswordSecretKeyRef + *out = new(user.SecretKeyRef) + **out = **in + } + if in.Prometheus != nil { + in, out := &in.Prometheus, &out.Prometheus + *out = new(v1.Prometheus) + **out = **in + } + if in.AutomationConfigOverride != nil { + in, out := &in.AutomationConfigOverride, &out.AutomationConfigOverride + *out = new(v1.AutomationConfigOverride) + (*in).DeepCopyInto(*out) + } + if in.MemberConfig != nil { + in, out := &in.MemberConfig, &out.MemberConfig + *out = make([]automationconfig.MemberOptions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppDBSpec. +func (in *AppDBSpec) DeepCopy() *AppDBSpec { + if in == nil { + return nil + } + out := new(AppDBSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AppDbBuilder) DeepCopyInto(out *AppDbBuilder) { + *out = *in + if in.appDb != nil { + in, out := &in.appDb, &out.appDb + *out = new(AppDBSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppDbBuilder. +func (in *AppDbBuilder) DeepCopy() *AppDbBuilder { + if in == nil { + return nil + } + out := new(AppDbBuilder) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AppDbStatus) DeepCopyInto(out *AppDbStatus) { + *out = *in + in.MongoDbStatus.DeepCopyInto(&out.MongoDbStatus) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppDbStatus. +func (in *AppDbStatus) DeepCopy() *AppDbStatus { + if in == nil { + return nil + } + out := new(AppDbStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BackupStatus) DeepCopyInto(out *BackupStatus) { + *out = *in + in.Common.DeepCopyInto(&out.Common) + if in.Warnings != nil { + in, out := &in.Warnings, &out.Warnings + *out = make([]status.Warning, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackupStatus. +func (in *BackupStatus) DeepCopy() *BackupStatus { + if in == nil { + return nil + } + out := new(BackupStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConnectionSpec) DeepCopyInto(out *ConnectionSpec) { + *out = *in + in.SharedConnectionSpec.DeepCopyInto(&out.SharedConnectionSpec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConnectionSpec. +func (in *ConnectionSpec) DeepCopy() *ConnectionSpec { + if in == nil { + return nil + } + out := new(ConnectionSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DataStoreConfig) DeepCopyInto(out *DataStoreConfig) { + *out = *in + out.MongoDBResourceRef = in.MongoDBResourceRef + if in.MongoDBUserRef != nil { + in, out := &in.MongoDBUserRef, &out.MongoDBUserRef + *out = new(MongoDBUserRef) + **out = **in + } + if in.AssignmentLabels != nil { + in, out := &in.AssignmentLabels, &out.AssignmentLabels + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataStoreConfig. +func (in *DataStoreConfig) DeepCopy() *DataStoreConfig { + if in == nil { + return nil + } + out := new(DataStoreConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Encryption) DeepCopyInto(out *Encryption) { + *out = *in + if in.Kmip != nil { + in, out := &in.Kmip, &out.Kmip + *out = new(KmipConfig) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Encryption. +func (in *Encryption) DeepCopy() *Encryption { + if in == nil { + return nil + } + out := new(Encryption) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FileSystemStoreConfig) DeepCopyInto(out *FileSystemStoreConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FileSystemStoreConfig. +func (in *FileSystemStoreConfig) DeepCopy() *FileSystemStoreConfig { + if in == nil { + return nil + } + out := new(FileSystemStoreConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KmipConfig) DeepCopyInto(out *KmipConfig) { + *out = *in + out.Server = in.Server +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KmipConfig. +func (in *KmipConfig) DeepCopy() *KmipConfig { + if in == nil { + return nil + } + out := new(KmipConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MongoDBOpsManager) DeepCopyInto(out *MongoDBOpsManager) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MongoDBOpsManager. +func (in *MongoDBOpsManager) DeepCopy() *MongoDBOpsManager { + if in == nil { + return nil + } + out := new(MongoDBOpsManager) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MongoDBOpsManager) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MongoDBOpsManagerBackup) DeepCopyInto(out *MongoDBOpsManagerBackup) { + *out = *in + if in.ExternalServiceEnabled != nil { + in, out := &in.ExternalServiceEnabled, &out.ExternalServiceEnabled + *out = new(bool) + **out = **in + } + if in.AssignmentLabels != nil { + in, out := &in.AssignmentLabels, &out.AssignmentLabels + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.HeadDB != nil { + in, out := &in.HeadDB, &out.HeadDB + *out = new(mdb.PersistenceConfig) + (*in).DeepCopyInto(*out) + } + if in.JVMParams != nil { + in, out := &in.JVMParams, &out.JVMParams + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.S3OplogStoreConfigs != nil { + in, out := &in.S3OplogStoreConfigs, &out.S3OplogStoreConfigs + *out = make([]S3Config, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.OplogStoreConfigs != nil { + in, out := &in.OplogStoreConfigs, &out.OplogStoreConfigs + *out = make([]DataStoreConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.BlockStoreConfigs != nil { + in, out := &in.BlockStoreConfigs, &out.BlockStoreConfigs + *out = make([]DataStoreConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.S3Configs != nil { + in, out := &in.S3Configs, &out.S3Configs + *out = make([]S3Config, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.FileSystemStoreConfigs != nil { + in, out := &in.FileSystemStoreConfigs, &out.FileSystemStoreConfigs + *out = make([]FileSystemStoreConfig, len(*in)) + copy(*out, *in) + } + if in.StatefulSetConfiguration != nil { + in, out := &in.StatefulSetConfiguration, &out.StatefulSetConfiguration + *out = new(v1.StatefulSetConfiguration) + (*in).DeepCopyInto(*out) + } + out.QueryableBackupSecretRef = in.QueryableBackupSecretRef + if in.Encryption != nil { + in, out := &in.Encryption, &out.Encryption + *out = new(Encryption) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MongoDBOpsManagerBackup. +func (in *MongoDBOpsManagerBackup) DeepCopy() *MongoDBOpsManagerBackup { + if in == nil { + return nil + } + out := new(MongoDBOpsManagerBackup) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MongoDBOpsManagerList) DeepCopyInto(out *MongoDBOpsManagerList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]MongoDBOpsManager, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MongoDBOpsManagerList. +func (in *MongoDBOpsManagerList) DeepCopy() *MongoDBOpsManagerList { + if in == nil { + return nil + } + out := new(MongoDBOpsManagerList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MongoDBOpsManagerList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MongoDBOpsManagerSecurity) DeepCopyInto(out *MongoDBOpsManagerSecurity) { + *out = *in + out.TLS = in.TLS +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MongoDBOpsManagerSecurity. +func (in *MongoDBOpsManagerSecurity) DeepCopy() *MongoDBOpsManagerSecurity { + if in == nil { + return nil + } + out := new(MongoDBOpsManagerSecurity) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MongoDBOpsManagerServiceDefinition) DeepCopyInto(out *MongoDBOpsManagerServiceDefinition) { + *out = *in + if in.Annotations != nil { + in, out := &in.Annotations, &out.Annotations + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MongoDBOpsManagerServiceDefinition. +func (in *MongoDBOpsManagerServiceDefinition) DeepCopy() *MongoDBOpsManagerServiceDefinition { + if in == nil { + return nil + } + out := new(MongoDBOpsManagerServiceDefinition) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MongoDBOpsManagerSpec) DeepCopyInto(out *MongoDBOpsManagerSpec) { + *out = *in + if in.Configuration != nil { + in, out := &in.Configuration, &out.Configuration + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + in.AppDB.DeepCopyInto(&out.AppDB) + if in.JVMParams != nil { + in, out := &in.JVMParams, &out.JVMParams + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Backup != nil { + in, out := &in.Backup, &out.Backup + *out = new(MongoDBOpsManagerBackup) + (*in).DeepCopyInto(*out) + } + if in.MongoDBOpsManagerExternalConnectivity != nil { + in, out := &in.MongoDBOpsManagerExternalConnectivity, &out.MongoDBOpsManagerExternalConnectivity + *out = new(MongoDBOpsManagerServiceDefinition) + (*in).DeepCopyInto(*out) + } + if in.Security != nil { + in, out := &in.Security, &out.Security + *out = new(MongoDBOpsManagerSecurity) + **out = **in + } + if in.StatefulSetConfiguration != nil { + in, out := &in.StatefulSetConfiguration, &out.StatefulSetConfiguration + *out = new(v1.StatefulSetConfiguration) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MongoDBOpsManagerSpec. +func (in *MongoDBOpsManagerSpec) DeepCopy() *MongoDBOpsManagerSpec { + if in == nil { + return nil + } + out := new(MongoDBOpsManagerSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MongoDBOpsManagerStatus) DeepCopyInto(out *MongoDBOpsManagerStatus) { + *out = *in + in.OpsManagerStatus.DeepCopyInto(&out.OpsManagerStatus) + in.AppDbStatus.DeepCopyInto(&out.AppDbStatus) + in.BackupStatus.DeepCopyInto(&out.BackupStatus) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MongoDBOpsManagerStatus. +func (in *MongoDBOpsManagerStatus) DeepCopy() *MongoDBOpsManagerStatus { + if in == nil { + return nil + } + out := new(MongoDBOpsManagerStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MongoDBOpsManagerTLS) DeepCopyInto(out *MongoDBOpsManagerTLS) { + *out = *in + out.SecretRef = in.SecretRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MongoDBOpsManagerTLS. +func (in *MongoDBOpsManagerTLS) DeepCopy() *MongoDBOpsManagerTLS { + if in == nil { + return nil + } + out := new(MongoDBOpsManagerTLS) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MongoDBUserRef) DeepCopyInto(out *MongoDBUserRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MongoDBUserRef. +func (in *MongoDBUserRef) DeepCopy() *MongoDBUserRef { + if in == nil { + return nil + } + out := new(MongoDBUserRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in OpsManagerAgentVersionMapping) DeepCopyInto(out *OpsManagerAgentVersionMapping) { + { + in := &in + *out = make(OpsManagerAgentVersionMapping, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpsManagerAgentVersionMapping. +func (in OpsManagerAgentVersionMapping) DeepCopy() OpsManagerAgentVersionMapping { + if in == nil { + return nil + } + out := new(OpsManagerAgentVersionMapping) + in.DeepCopyInto(out) + return *out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OpsManagerBuilder) DeepCopyInto(out *OpsManagerBuilder) { + *out = *in + in.om.DeepCopyInto(&out.om) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpsManagerBuilder. +func (in *OpsManagerBuilder) DeepCopy() *OpsManagerBuilder { + if in == nil { + return nil + } + out := new(OpsManagerBuilder) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OpsManagerStatus) DeepCopyInto(out *OpsManagerStatus) { + *out = *in + in.Common.DeepCopyInto(&out.Common) + if in.Warnings != nil { + in, out := &in.Warnings, &out.Warnings + *out = make([]status.Warning, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpsManagerStatus. +func (in *OpsManagerStatus) DeepCopy() *OpsManagerStatus { + if in == nil { + return nil + } + out := new(OpsManagerStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *S3Config) DeepCopyInto(out *S3Config) { + *out = *in + if in.MongoDBResourceRef != nil { + in, out := &in.MongoDBResourceRef, &out.MongoDBResourceRef + *out = new(user.MongoDBResourceRef) + **out = **in + } + if in.MongoDBUserRef != nil { + in, out := &in.MongoDBUserRef, &out.MongoDBUserRef + *out = new(MongoDBUserRef) + **out = **in + } + out.S3SecretRef = in.S3SecretRef + if in.AssignmentLabels != nil { + in, out := &in.AssignmentLabels, &out.AssignmentLabels + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new S3Config. +func (in *S3Config) DeepCopy() *S3Config { + if in == nil { + return nil + } + out := new(S3Config) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecretRef) DeepCopyInto(out *SecretRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretRef. +func (in *SecretRef) DeepCopy() *SecretRef { + if in == nil { + return nil + } + out := new(SecretRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TLSSecretRef) DeepCopyInto(out *TLSSecretRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSSecretRef. +func (in *TLSSecretRef) DeepCopy() *TLSSecretRef { + if in == nil { + return nil + } + out := new(TLSSecretRef) + in.DeepCopyInto(out) + return out +} diff --git a/api/v1/register.go b/api/v1/register.go new file mode 100644 index 000000000..2efd9821b --- /dev/null +++ b/api/v1/register.go @@ -0,0 +1,19 @@ +// NOTE: Boilerplate only. Ignore this file. + +// Package v1 contains API Schema definitions for the mongodb v1 API group +// +k8s:deepcopy-gen=package,register +// +groupName=mongodb.com +package v1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // SchemeGroupVersion is group version used to register these objects + SchemeGroupVersion = schema.GroupVersion{Group: "mongodb.com", Version: "v1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: SchemeGroupVersion} +) diff --git a/api/v1/status/doc.go b/api/v1/status/doc.go new file mode 100644 index 000000000..6c12ae38b --- /dev/null +++ b/api/v1/status/doc.go @@ -0,0 +1 @@ +package status diff --git a/api/v1/status/option.go b/api/v1/status/option.go new file mode 100644 index 000000000..ba45cf44e --- /dev/null +++ b/api/v1/status/option.go @@ -0,0 +1,103 @@ +package status + +import ( + "reflect" +) + +type Option interface { + Value() interface{} +} + +type noOption struct{} + +func (o noOption) Value() interface{} { + return nil +} + +// MessageOption describes the status message +type MessageOption struct { + Message string +} + +func NewMessageOption(message string) MessageOption { + return MessageOption{Message: message} +} + +func (o MessageOption) Value() interface{} { + return o.Message +} + +// WarningsOption describes the status warnings +type WarningsOption struct { + Warnings []Warning +} + +func NewWarningsOption(warnings []Warning) WarningsOption { + return WarningsOption{Warnings: warnings} +} + +func (o WarningsOption) Value() interface{} { + return o.Warnings +} + +// BaseUrlOption describes the Ops Manager base URL. +type BaseUrlOption struct { + BaseUrl string +} + +func NewBaseUrlOption(baseUrl string) BaseUrlOption { + return BaseUrlOption{BaseUrl: baseUrl} +} + +func (o BaseUrlOption) Value() interface{} { + return o.BaseUrl +} + +// OMPartOption describes the part of Ops Manager resource status to be updated +type OMPartOption struct { + StatusPart Part +} + +func NewOMPartOption(statusPart Part) OMPartOption { + return OMPartOption{StatusPart: statusPart} +} + +func (o OMPartOption) Value() interface{} { + return o.StatusPart +} + +// ResourcesNotReadyOption describes the resources dependent on the resource which are not ready +type ResourcesNotReadyOption struct { + ResourcesNotReady []ResourceNotReady +} + +func NewResourcesNotReadyOption(resourceNotReady []ResourceNotReady) ResourcesNotReadyOption { + return ResourcesNotReadyOption{ResourcesNotReady: resourceNotReady} +} + +func (o ResourcesNotReadyOption) Value() interface{} { + return o.ResourcesNotReady +} + +type BackupStatusOption struct { + statusName string +} + +func NewBackupStatusOption(statusName string) BackupStatusOption { + return BackupStatusOption{ + statusName: statusName, + } +} + +func (o BackupStatusOption) Value() interface{} { + return o.statusName +} + +func GetOption(statusOptions []Option, targetOption Option) (Option, bool) { + for _, s := range statusOptions { + if reflect.TypeOf(s) == reflect.TypeOf(targetOption) { + return s, true + } + } + return noOption{}, false +} diff --git a/api/v1/status/part.go b/api/v1/status/part.go new file mode 100644 index 000000000..c71722cd5 --- /dev/null +++ b/api/v1/status/part.go @@ -0,0 +1,11 @@ +package status + +// Part is the logical constant for specific field in status in the MongoDBOpsManager +type Part int + +const ( + AppDb Part = iota + OpsManager + Backup + None +) diff --git a/api/v1/status/phase.go b/api/v1/status/phase.go new file mode 100644 index 000000000..753bf148d --- /dev/null +++ b/api/v1/status/phase.go @@ -0,0 +1,27 @@ +package status + +type Phase string + +const ( + // PhaseReconciling means the controller is in the middle of reconciliation process + PhaseReconciling Phase = "Reconciling" + + // PhasePending means the reconciliation has finished but the resource is neither in Error nor Running state - + // most of all waiting for some event to happen (CSRs approved, shard rebalanced etc) + PhasePending Phase = "Pending" + + // PhaseRunning means the Mongodb Resource is in a running state + PhaseRunning Phase = "Running" + + // PhaseFailed means the Mongodb Resource is in a failed state + PhaseFailed Phase = "Failed" + + // PhaseDisabled means that the resource is not enabled + PhaseDisabled Phase = "Disabled" + + // PhaseUpdated means a MongoDBUser was successfully updated + PhaseUpdated Phase = "Updated" + + // PhaseUnsupported means a resource is not supported by the current Operator version + PhaseUnsupported Phase = "Unsupported" +) diff --git a/api/v1/status/resourcenotready.go b/api/v1/status/resourcenotready.go new file mode 100644 index 000000000..ff470c018 --- /dev/null +++ b/api/v1/status/resourcenotready.go @@ -0,0 +1,23 @@ +package status + +// ResourceKind specifies a kind of a Kubernetes resource. Used in status of a Custom Resource +type ResourceKind string + +const ( + StatefulsetKind ResourceKind = "StatefulSet" +) + +// ResourceNotReady describes the dependent resource which is not ready yet +// +k8s:deepcopy-gen=true +type ResourceNotReady struct { + Kind ResourceKind `json:"kind"` + Name string `json:"name"` + Errors []ResourceError `json:"errors,omitempty"` + Message string `json:"message,omitempty"` +} + +// +k8s:deepcopy-gen=true +type ResourceError struct { + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` +} diff --git a/api/v1/status/scaling_status.go b/api/v1/status/scaling_status.go new file mode 100644 index 000000000..b84929826 --- /dev/null +++ b/api/v1/status/scaling_status.go @@ -0,0 +1,55 @@ +package status + +import ( + "github.com/mongodb/mongodb-kubernetes-operator/pkg/util/scale" +) + +func MembersOption(replicaSetscaler scale.ReplicaSetScaler) Option { + return ReplicaSetMembersOption{Members: scale.ReplicasThisReconciliation(replicaSetscaler)} +} + +func MongosCountOption(replicaSetscaler scale.ReplicaSetScaler) Option { + return ShardedClusterMongosOption{Members: scale.ReplicasThisReconciliation(replicaSetscaler)} +} +func ConfigServerOption(replicaSetscaler scale.ReplicaSetScaler) Option { + return ShardedClusterConfigServerOption{Members: scale.ReplicasThisReconciliation(replicaSetscaler)} +} + +func MongodsPerShardOption(replicaSetscaler scale.ReplicaSetScaler) Option { + return ShardedClusterMongodsPerShardCountOption{Members: scale.ReplicasThisReconciliation(replicaSetscaler)} +} + +// ReplicaSetMembersOption is required in order to ensure that the status of a resource +// is only updated one member at a time. The logic which scales incrementally relies +// on the current status of the resource to accurate +type ReplicaSetMembersOption struct { + Members int +} + +func (o ReplicaSetMembersOption) Value() interface{} { + return o.Members +} + +type ShardedClusterMongodsPerShardCountOption struct { + Members int +} + +func (o ShardedClusterMongodsPerShardCountOption) Value() interface{} { + return o.Members +} + +type ShardedClusterConfigServerOption struct { + Members int +} + +func (o ShardedClusterConfigServerOption) Value() interface{} { + return o.Members +} + +type ShardedClusterMongosOption struct { + Members int +} + +func (o ShardedClusterMongosOption) Value() interface{} { + return o.Members +} diff --git a/api/v1/status/status.go b/api/v1/status/status.go new file mode 100644 index 000000000..cef2f7b17 --- /dev/null +++ b/api/v1/status/status.go @@ -0,0 +1,49 @@ +package status + +import ( + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" + "github.com/10gen/ops-manager-kubernetes/pkg/util/timeutil" +) + +type Writer interface { + // UpdateStatus updates the status of the object + UpdateStatus(phase Phase, statusOptions ...Option) + + // SetWarnings sets the warnings for the object, the list of Options + // provided indicate which Status subresource should be updated in the case + // of AppDB, OpsManager and Backup + SetWarnings([]Warning, ...Option) + + // GetStatusPath should return the path that should be used + // to patch the Status object + GetStatusPath(options ...Option) string +} + +type Reader interface { + // GetStatus returns the status of the object. The list of Options + // provided indicates which subresource will be returned. AppDB, OM or Backup + GetStatus(options ...Option) interface{} +} + +// Common is the struct shared by all statuses in existing Custom Resources. +// +kubebuilder:object:generate:=true +type Common struct { + Phase Phase `json:"phase"` + Message string `json:"message,omitempty"` + LastTransition string `json:"lastTransition,omitempty"` + ResourcesNotReady []ResourceNotReady `json:"resourcesNotReady,omitempty"` + ObservedGeneration int64 `json:"observedGeneration,omitempty"` +} + +// UpdateCommonFields is the update function to update common fields used in statuses of all managed CRs +func (s *Common) UpdateCommonFields(phase Phase, generation int64, statusOptions ...Option) { + s.Phase = phase + s.LastTransition = timeutil.Now() + s.ObservedGeneration = generation + if option, exists := GetOption(statusOptions, MessageOption{}); exists { + s.Message = stringutil.UpperCaseFirstChar(option.(MessageOption).Message) + } + if option, exists := GetOption(statusOptions, ResourcesNotReadyOption{}); exists { + s.ResourcesNotReady = option.(ResourcesNotReadyOption).ResourcesNotReady + } +} diff --git a/api/v1/status/warnings.go b/api/v1/status/warnings.go new file mode 100644 index 000000000..0a472051e --- /dev/null +++ b/api/v1/status/warnings.go @@ -0,0 +1,27 @@ +package status + +type Warning string + +type Warnings []Warning + +const ( + SEP Warning = ";" +) + +func (m Warnings) AddIfNotExists(warning Warning) Warnings { + for _, existingWarning := range m { + if existingWarning == warning || existingWarning == warning+SEP { + return m + } + } + + // separate warnings by a ; + for i := 0; i < len(m); i++ { + existingWarning := m[i] + if existingWarning[len(existingWarning)-1:] != SEP { + m[i] += SEP + } + } + + return append(m, warning) +} diff --git a/api/v1/status/zz_generated.deepcopy.go b/api/v1/status/zz_generated.deepcopy.go new file mode 100644 index 000000000..b3b63d0f2 --- /dev/null +++ b/api/v1/status/zz_generated.deepcopy.go @@ -0,0 +1,81 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright 2021. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package status + +import () + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Common) DeepCopyInto(out *Common) { + *out = *in + if in.ResourcesNotReady != nil { + in, out := &in.ResourcesNotReady, &out.ResourcesNotReady + *out = make([]ResourceNotReady, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Common. +func (in *Common) DeepCopy() *Common { + if in == nil { + return nil + } + out := new(Common) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResourceError) DeepCopyInto(out *ResourceError) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceError. +func (in *ResourceError) DeepCopy() *ResourceError { + if in == nil { + return nil + } + out := new(ResourceError) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResourceNotReady) DeepCopyInto(out *ResourceNotReady) { + *out = *in + if in.Errors != nil { + in, out := &in.Errors, &out.Errors + *out = make([]ResourceError, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceNotReady. +func (in *ResourceNotReady) DeepCopy() *ResourceNotReady { + if in == nil { + return nil + } + out := new(ResourceNotReady) + in.DeepCopyInto(out) + return out +} diff --git a/api/v1/user/doc.go b/api/v1/user/doc.go new file mode 100644 index 000000000..bb014d2e2 --- /dev/null +++ b/api/v1/user/doc.go @@ -0,0 +1,4 @@ +package user + +// +k8s:deepcopy-gen=package +// +versionName=v1 diff --git a/api/v1/user/groupversion_info.go b/api/v1/user/groupversion_info.go new file mode 100644 index 000000000..38b73e558 --- /dev/null +++ b/api/v1/user/groupversion_info.go @@ -0,0 +1,20 @@ +// Package v1 contains API Schema definitions for the mongodb v1 API group +// +kubebuilder:object:generate=true +// +groupName=mongodb.com +package user + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects + GroupVersion = schema.GroupVersion{Group: "mongodb.com", Version: "v1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/api/v1/user/mongodbuser_types.go b/api/v1/user/mongodbuser_types.go new file mode 100644 index 000000000..147b33dab --- /dev/null +++ b/api/v1/user/mongodbuser_types.go @@ -0,0 +1,198 @@ +package user + +import ( + "fmt" + "regexp" + "strings" + + v1 "github.com/10gen/ops-manager-kubernetes/api/v1" + "github.com/10gen/ops-manager-kubernetes/pkg/vault" + "golang.org/x/xerrors" + + "github.com/10gen/ops-manager-kubernetes/api/v1/status" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/secrets" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func init() { + v1.SchemeBuilder.Register(&MongoDBUser{}, &MongoDBUserList{}) +} + +// The MongoDBUser resource allows you to create, deletion and configure +// users for your MongoDB deployments + +// +kubebuilder:object:root=true +// +k8s:openapi-gen=true +// +kubebuilder:resource:shortName=mdbu +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase",description="The current state of the MongoDB User." +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="The time since the MongoDB User resource was created." +type MongoDBUser struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + // +optional + Status MongoDBUserStatus `json:"status"` + Spec MongoDBUserSpec `json:"spec"` +} + +// GetPassword returns the password of the user as stored in the referenced +// secret. If the password secret reference is unset then a blank password and +// a nil error will be returned. +func (user MongoDBUser) GetPassword(secretClient secrets.SecretClient) (string, error) { + if user.Spec.PasswordSecretKeyRef.Name == "" { + return "", nil + } + + nsName := client.ObjectKey{ + Namespace: user.Namespace, + Name: user.Spec.PasswordSecretKeyRef.Name, + } + var databaseSecretPath string + if vault.IsVaultSecretBackend() { + databaseSecretPath = secretClient.VaultClient.DatabaseSecretPath() + } + secretData, err := secretClient.ReadSecret(nsName, databaseSecretPath) + if err != nil { + return "", xerrors.Errorf("could not retrieve user password secret: %w", err) + } + + passwordBytes, passwordIsSet := secretData[user.Spec.PasswordSecretKeyRef.Key] + if !passwordIsSet { + return "", xerrors.Errorf("password is not set in password secret") + } + + return passwordBytes, nil +} + +// SecretKeyRef is a reference to a value in a given secret in the same +// namespace. Based on: +// https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.15/#secretkeyselector-v1-core +type SecretKeyRef struct { + Name string `json:"name"` + Key string `json:"key,omitempty"` +} + +type MongoDBResourceRef struct { + Name string `json:"name"` + Namespace string `json:"namespace,omitempty"` +} + +type MongoDBUserSpec struct { + Roles []Role `json:"roles,omitempty"` + Username string `json:"username"` + Database string `json:"db"` + // +optional + MongoDBResourceRef MongoDBResourceRef `json:"mongodbResourceRef"` + // +optional + PasswordSecretKeyRef SecretKeyRef `json:"passwordSecretKeyRef"` + // +optional + ConnectionStringSecretName string `json:"connectionStringSecretName"` +} + +type MongoDBUserStatus struct { + status.Common `json:",inline"` + Roles []Role `json:"roles,omitempty"` + Username string `json:"username"` + Database string `json:"db"` + Project string `json:"project"` + Warnings []status.Warning `json:"warnings,omitempty"` +} + +type Role struct { + RoleName string `json:"name"` + Database string `json:"db"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type MongoDBUserList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + Items []MongoDBUser `json:"items"` +} + +// Changed identifier determines if the user has changed a value that is used in +// uniquely identifying them. Either username or db. This function relies on the status +// of the resource and is required in order to remove the old user before +// adding a new one to avoid leaving stale state in Ops Manger. +func (u *MongoDBUser) ChangedIdentifier() bool { + if u.Status.Username == "" || u.Status.Database == "" { + return false + } + return u.Status.Username != u.Spec.Username || u.Status.Database != u.Spec.Database +} + +func (u *MongoDBUser) UpdateStatus(phase status.Phase, statusOptions ...status.Option) { + u.Status.UpdateCommonFields(phase, u.GetGeneration(), statusOptions...) + if option, exists := status.GetOption(statusOptions, status.WarningsOption{}); exists { + u.Status.Warnings = append(u.Status.Warnings, option.(status.WarningsOption).Warnings...) + } + + if phase == status.PhaseRunning { + u.Status.Phase = status.PhaseUpdated + u.Status.Roles = u.Spec.Roles + u.Status.Database = u.Spec.Database + u.Status.Username = u.Spec.Username + } +} + +func (u MongoDBUser) GetConnectionStringSecretName() string { + if u.Spec.ConnectionStringSecretName != "" { + return u.Spec.ConnectionStringSecretName + } + var resourceRef string + if u.Spec.MongoDBResourceRef.Name != "" { + resourceRef = u.Spec.MongoDBResourceRef.Name + "-" + } + + database := u.Spec.Database + if database == "$external" { + database = strings.TrimPrefix(database, "$") + } + + return normalizeName(fmt.Sprintf("%s%s-%s", resourceRef, u.Name, database)) +} + +// normalizeName returns a string that conforms to RFC-1123. +// This logic is duplicated in the community operator in https://github.com/mongodb/mongodb-kubernetes-operator/blob/master/api/v1/mongodbcommunity_types.go. +// The logic should be reused if/when we unify the user types or observe that the logic needs to be changed for business logic reasons, to avoid modifying it +// in separate places in the future. +func normalizeName(name string) string { + errors := validation.IsDNS1123Subdomain(name) + if len(errors) == 0 { + return name + } + + // convert name to lowercase and replace invalid characters with '-' + name = strings.ToLower(name) + re := regexp.MustCompile("[^a-z0-9-]+") + name = re.ReplaceAllString(name, "-") + + // Remove duplicate `-` resulting from contiguous non-allowed chars. + re = regexp.MustCompile(`\-+`) + name = re.ReplaceAllString(name, "-") + + name = strings.Trim(name, "-") + + if len(name) > validation.DNS1123SubdomainMaxLength { + name = name[0:validation.DNS1123SubdomainMaxLength] + } + return name +} + +func (m *MongoDBUser) SetWarnings(warnings []status.Warning, _ ...status.Option) { + m.Status.Warnings = warnings +} + +func (m MongoDBUser) GetPlural() string { + return "mongodbusers" +} + +func (u *MongoDBUser) GetStatus(...status.Option) interface{} { + return u.Status +} + +func (u MongoDBUser) GetStatusPath(...status.Option) string { + return "/status" +} diff --git a/api/v1/user/mongodbuser_types_test.go b/api/v1/user/mongodbuser_types_test.go new file mode 100644 index 000000000..ba9eefdc6 --- /dev/null +++ b/api/v1/user/mongodbuser_types_test.go @@ -0,0 +1,42 @@ +package user + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMongoDBUser_ChangedIdentifier(t *testing.T) { + before := MongoDBUser{ + Spec: MongoDBUserSpec{ + Username: "before-name", + Database: "before-db", + }, + } + + after := MongoDBUser{ + Spec: MongoDBUserSpec{ + Username: "after-name", + Database: "after-db", + }, + Status: MongoDBUserStatus{ + Username: "before-name", + Database: "before-db", + }, + } + + assert.False(t, before.ChangedIdentifier(), "Status has not be initialized yet so the identifier should not have changed") + assert.True(t, after.ChangedIdentifier(), "Status differs from Spec, so identifier should have changed") + + before = MongoDBUser{ + Spec: MongoDBUserSpec{ + Username: "before-name", + Database: "before-db", + }, + Status: MongoDBUserStatus{ + Username: "before-name", + Database: "before-db", + }, + } + assert.False(t, before.ChangedIdentifier(), "Identifier before and after are the same, identifier should not have changed") +} diff --git a/api/v1/user/zz_generated.deepcopy.go b/api/v1/user/zz_generated.deepcopy.go new file mode 100644 index 000000000..6e86379c8 --- /dev/null +++ b/api/v1/user/zz_generated.deepcopy.go @@ -0,0 +1,179 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright 2021. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package user + +import ( + "github.com/10gen/ops-manager-kubernetes/api/v1/status" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MongoDBResourceRef) DeepCopyInto(out *MongoDBResourceRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MongoDBResourceRef. +func (in *MongoDBResourceRef) DeepCopy() *MongoDBResourceRef { + if in == nil { + return nil + } + out := new(MongoDBResourceRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MongoDBUser) DeepCopyInto(out *MongoDBUser) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Status.DeepCopyInto(&out.Status) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MongoDBUser. +func (in *MongoDBUser) DeepCopy() *MongoDBUser { + if in == nil { + return nil + } + out := new(MongoDBUser) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MongoDBUser) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MongoDBUserList) DeepCopyInto(out *MongoDBUserList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]MongoDBUser, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MongoDBUserList. +func (in *MongoDBUserList) DeepCopy() *MongoDBUserList { + if in == nil { + return nil + } + out := new(MongoDBUserList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MongoDBUserList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MongoDBUserSpec) DeepCopyInto(out *MongoDBUserSpec) { + *out = *in + if in.Roles != nil { + in, out := &in.Roles, &out.Roles + *out = make([]Role, len(*in)) + copy(*out, *in) + } + out.MongoDBResourceRef = in.MongoDBResourceRef + out.PasswordSecretKeyRef = in.PasswordSecretKeyRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MongoDBUserSpec. +func (in *MongoDBUserSpec) DeepCopy() *MongoDBUserSpec { + if in == nil { + return nil + } + out := new(MongoDBUserSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MongoDBUserStatus) DeepCopyInto(out *MongoDBUserStatus) { + *out = *in + in.Common.DeepCopyInto(&out.Common) + if in.Roles != nil { + in, out := &in.Roles, &out.Roles + *out = make([]Role, len(*in)) + copy(*out, *in) + } + if in.Warnings != nil { + in, out := &in.Warnings, &out.Warnings + *out = make([]status.Warning, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MongoDBUserStatus. +func (in *MongoDBUserStatus) DeepCopy() *MongoDBUserStatus { + if in == nil { + return nil + } + out := new(MongoDBUserStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Role) DeepCopyInto(out *Role) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Role. +func (in *Role) DeepCopy() *Role { + if in == nil { + return nil + } + out := new(Role) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecretKeyRef) DeepCopyInto(out *SecretKeyRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretKeyRef. +func (in *SecretKeyRef) DeepCopy() *SecretKeyRef { + if in == nil { + return nil + } + out := new(SecretKeyRef) + in.DeepCopyInto(out) + return out +} diff --git a/api/v1/validation.go b/api/v1/validation.go new file mode 100644 index 000000000..e4d3ff9a6 --- /dev/null +++ b/api/v1/validation.go @@ -0,0 +1,52 @@ +package v1 + +import ( + "errors" + "fmt" + "strings" + + "github.com/10gen/ops-manager-kubernetes/api/v1/status" +) + +type validationLevel int + +const ( + SuccessLevel validationLevel = iota + WarningLevel + ErrorLevel +) + +type ValidationResult struct { + Msg string + Level validationLevel + // OmStatusPart indicates which Warnings array this ValidationResult + // should correspond to. Either OpsManager, AppDB or Backup + OmStatusPart status.Part +} + +func ValidationSuccess() ValidationResult { + return ValidationResult{Level: SuccessLevel} +} + +func ValidationWarning(msg string, params ...interface{}) ValidationResult { + return ValidationResult{Msg: fmt.Sprintf(msg, params...), Level: WarningLevel} +} + +func ValidationError(msg string, params ...interface{}) ValidationResult { + return ValidationResult{Msg: fmt.Sprintf(msg, params...), Level: ErrorLevel} +} + +func OpsManagerResourceValidationError(msg string, part status.Part, params ...interface{}) ValidationResult { + return ValidationResult{Msg: fmt.Sprintf(msg, params...), Level: ErrorLevel, OmStatusPart: part} +} + +func BuildValidationFailure(results []ValidationResult) error { + var errorMsg []string + if len(results) == 1 { + return errors.New(results[0].Msg) + } + for _, err := range results { + errorMsg = append(errorMsg, err.Msg) + } + return errors.New(strings.Join(errorMsg[:], ",")) +} diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go new file mode 100644 index 000000000..3ed49877c --- /dev/null +++ b/api/v1/zz_generated.deepcopy.go @@ -0,0 +1,69 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright 2021. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v1 + +import () + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KmipClientConfig) DeepCopyInto(out *KmipClientConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KmipClientConfig. +func (in *KmipClientConfig) DeepCopy() *KmipClientConfig { + if in == nil { + return nil + } + out := new(KmipClientConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KmipServerConfig) DeepCopyInto(out *KmipServerConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KmipServerConfig. +func (in *KmipServerConfig) DeepCopy() *KmipServerConfig { + if in == nil { + return nil + } + out := new(KmipServerConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ValidationResult) DeepCopyInto(out *ValidationResult) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ValidationResult. +func (in *ValidationResult) DeepCopy() *ValidationResult { + if in == nil { + return nil + } + out := new(ValidationResult) + in.DeepCopyInto(out) + return out +} diff --git a/config/crd/bases/mongodb.com_mongodb.yaml b/config/crd/bases/mongodb.com_mongodb.yaml new file mode 100644 index 000000000..62d2c29c5 --- /dev/null +++ b/config/crd/bases/mongodb.com_mongodb.yaml @@ -0,0 +1,972 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.10.0 + creationTimestamp: null + name: mongodb.mongodb.com +spec: + group: mongodb.com + names: + kind: MongoDB + listKind: MongoDBList + plural: mongodb + shortNames: + - mdb + singular: mongodb + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Current state of the MongoDB deployment. + jsonPath: .status.phase + name: Phase + type: string + - description: Version of MongoDB server. + jsonPath: .status.version + name: Version + type: string + - description: The type of MongoDB deployment. One of 'ReplicaSet', 'ShardedCluster' + and 'Standalone'. + jsonPath: .spec.type + name: Type + type: string + - description: The time since the MongoDB resource was created. + jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + properties: + additionalMongodConfig: + description: 'AdditionalMongodConfig is additional configuration that + can be passed to each data-bearing mongod at runtime. Uses the same + structure as the mongod configuration file: https://docs.mongodb.com/manual/reference/configuration-options/' + type: object + x-kubernetes-preserve-unknown-fields: true + agent: + properties: + logLevel: + type: string + maxLogFileDurationHours: + type: integer + startupOptions: + additionalProperties: + type: string + type: object + type: object + backup: + description: Backup contains configuration options for configuring + backup for this MongoDB resource + properties: + assignmentLabels: + description: Assignment Labels set in the Ops Manager + items: + type: string + type: array + autoTerminateOnDeletion: + description: AutoTerminateOnDeletion indicates if the Operator + should stop and terminate the Backup before the cleanup, when + the MongoDB CR is deleted + type: boolean + encryption: + description: Encryption settings + properties: + kmip: + description: Kmip corresponds to the KMIP configuration assigned + to the Ops Manager Project's configuration. + properties: + client: + description: KMIP Client configuration + properties: + clientCertificatePrefix: + description: 'A prefix used to construct KMIP client + certificate (and corresponding password) Secret + names. The names are generated using the following + pattern: KMIP Client Certificate (TLS Secret): --kmip-client KMIP Client Certificate Password: + --kmip-client-password + The expected key inside is called "password".' + type: string + type: object + required: + - client + type: object + type: object + mode: + enum: + - enabled + - disabled + - terminated + type: string + snapshotSchedule: + properties: + clusterCheckpointIntervalMin: + enum: + - 15 + - 30 + - 60 + type: integer + dailySnapshotRetentionDays: + description: Number of days to retain daily snapshots. Setting + 0 will disable this rule. + maximum: 365 + minimum: 0 + type: integer + fullIncrementalDayOfWeek: + description: Day of the week when Ops Manager takes a full + snapshot. This ensures a recent complete backup. Ops Manager + sets the default value to SUNDAY. + enum: + - SUNDAY + - MONDAY + - TUESDAY + - WEDNESDAY + - THURSDAY + - FRIDAY + - SATURDAY + type: string + monthlySnapshotRetentionMonths: + description: Number of months to retain weekly snapshots. + Setting 0 will disable this rule. + maximum: 36 + minimum: 0 + type: integer + pointInTimeWindowHours: + description: Number of hours in the past for which a point-in-time + snapshot can be created. + enum: + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 15 + - 30 + - 60 + - 90 + - 120 + - 180 + - 360 + type: integer + referenceHourOfDay: + description: Hour of the day to schedule snapshots using a + 24-hour clock, in UTC. + maximum: 23 + minimum: 0 + type: integer + referenceMinuteOfHour: + description: Minute of the hour to schedule snapshots, in + UTC. + maximum: 59 + minimum: 0 + type: integer + snapshotIntervalHours: + description: Number of hours between snapshots. + enum: + - 6 + - 8 + - 12 + - 24 + type: integer + snapshotRetentionDays: + description: Number of days to keep recent snapshots. + maximum: 365 + minimum: 1 + type: integer + weeklySnapshotRetentionWeeks: + description: Number of weeks to retain weekly snapshots. Setting + 0 will disable this rule + maximum: 365 + minimum: 0 + type: integer + type: object + type: object + cloudManager: + properties: + configMapRef: + properties: + name: + type: string + type: object + type: object + clusterDomain: + format: hostname + type: string + configServerCount: + type: integer + configSrv: + properties: + additionalMongodConfig: + type: object + x-kubernetes-preserve-unknown-fields: true + agent: + properties: + logLevel: + type: string + maxLogFileDurationHours: + type: integer + startupOptions: + additionalProperties: + type: string + type: object + type: object + type: object + x-kubernetes-preserve-unknown-fields: true + configSrvPodSpec: + properties: + persistence: + properties: + multiple: + properties: + data: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + journal: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + logs: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + single: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + podTemplate: + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + connectivity: + properties: + replicaSetHorizons: + description: 'ReplicaSetHorizons holds list of maps of horizons + to be configured in each of MongoDB processes. Horizons map + horizon names to the node addresses for each process in the + replicaset, e.g.: [ { "internal": "my-rs-0.my-internal-domain.com:31843", + "external": "my-rs-0.my-external-domain.com:21467" }, { "internal": + "my-rs-1.my-internal-domain.com:31843", "external": "my-rs-1.my-external-domain.com:21467" + }, ... ] The key of each item in the map is an arbitrary, user-chosen + string that represents the name of the horizon. The value of + the item is the host and, optionally, the port that this mongod + node will be connected to from.' + items: + additionalProperties: + type: string + type: object + type: array + type: object + credentials: + description: Name of the Secret holding credentials information + type: string + exposedExternally: + description: 'DEPRECATED: use ExternalAccessConfiguration instead' + type: boolean + externalAccess: + description: ExternalAccessConfiguration provides external access + configuration. + properties: + externalDomain: + description: An external domain that is used for exposing MongoDB + to the outside world. + type: string + externalService: + description: Provides a way to override the default (NodePort) + Service + properties: + annotations: + additionalProperties: + type: string + description: A map of annotations that shall be added to the + externally available Service. + type: object + spec: + description: A wrapper for the Service spec object. + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + type: object + featureCompatibilityVersion: + type: string + logLevel: + enum: + - DEBUG + - INFO + - WARN + - ERROR + - FATAL + type: string + memberConfig: + description: MemberConfig + items: + properties: + priority: + type: string + tags: + additionalProperties: + type: string + type: object + votes: + type: integer + type: object + type: array + x-kubernetes-preserve-unknown-fields: true + members: + description: Amount of members for this MongoDB Replica Set + type: integer + mongodsPerShardCount: + type: integer + mongos: + properties: + additionalMongodConfig: + type: object + x-kubernetes-preserve-unknown-fields: true + agent: + properties: + logLevel: + type: string + maxLogFileDurationHours: + type: integer + startupOptions: + additionalProperties: + type: string + type: object + type: object + type: object + x-kubernetes-preserve-unknown-fields: true + mongosCount: + type: integer + mongosPodSpec: + properties: + persistence: + properties: + multiple: + properties: + data: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + journal: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + logs: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + single: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + podTemplate: + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + opsManager: + properties: + configMapRef: + properties: + name: + type: string + type: object + type: object + persistent: + type: boolean + podSpec: + properties: + persistence: + properties: + multiple: + properties: + data: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + journal: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + logs: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + single: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + podTemplate: + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + prometheus: + description: Prometheus configurations. + properties: + metricsPath: + description: Indicates path to the metrics endpoint. + pattern: ^\/[a-z0-9]+$ + type: string + passwordSecretRef: + description: Name of a Secret containing a HTTP Basic Auth Password. + properties: + key: + description: Key is the key in the secret storing this password. + Defaults to "password" + type: string + name: + description: Name is the name of the secret storing this user's + password + type: string + required: + - name + type: object + port: + description: Port where metrics endpoint will bind to. Defaults + to 9216. + type: integer + tlsSecretKeyRef: + description: Name of a Secret (type kubernetes.io/tls) holding + the certificates to use in the Prometheus endpoint. + properties: + key: + description: Key is the key in the secret storing this password. + Defaults to "password" + type: string + name: + description: Name is the name of the secret storing this user's + password + type: string + required: + - name + type: object + username: + description: HTTP Basic Auth Username for metrics endpoint. + type: string + required: + - passwordSecretRef + - username + type: object + security: + properties: + authentication: + description: Authentication holds various authentication related + settings that affect this MongoDB resource. + properties: + agents: + description: Agents contains authentication configuration + properties for the agents + properties: + automationLdapGroupDN: + type: string + automationPasswordSecretRef: + description: SecretKeySelector selects a key of a Secret. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, + uid?' + type: string + optional: + description: Specify whether the Secret or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + automationUserName: + type: string + clientCertificateSecretRef: + type: object + x-kubernetes-preserve-unknown-fields: true + mode: + description: Mode is the desired Authentication mode that + the agents will use + type: string + required: + - mode + type: object + enabled: + type: boolean + ignoreUnknownUsers: + description: IgnoreUnknownUsers maps to the inverse of auth.authoritativeSet + type: boolean + internalCluster: + type: string + ldap: + description: LDAP Configuration + properties: + authzQueryTemplate: + type: string + bindQueryPasswordSecretRef: + properties: + name: + type: string + required: + - name + type: object + bindQueryUser: + type: string + caConfigMapRef: + description: Allows to point at a ConfigMap/key with a + CA file to mount on the Pod + properties: + key: + description: The key to select. + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, + uid?' + type: string + optional: + description: Specify whether the ConfigMap or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + servers: + items: + type: string + type: array + timeoutMS: + type: integer + transportSecurity: + enum: + - tls + - none + type: string + userCacheInvalidationInterval: + type: integer + userToDNMapping: + type: string + validateLDAPServerConfig: + type: boolean + type: object + modes: + items: + type: string + type: array + requireClientTLSAuthentication: + description: Clients should present valid TLS certificates + type: boolean + required: + - enabled + type: object + certsSecretPrefix: + type: string + roles: + items: + properties: + authenticationRestrictions: + items: + properties: + clientSource: + items: + type: string + type: array + serverAddress: + items: + type: string + type: array + type: object + type: array + db: + type: string + privileges: + items: + properties: + actions: + items: + type: string + type: array + resource: + properties: + cluster: + type: boolean + collection: + type: string + db: + type: string + type: object + required: + - actions + - resource + type: object + type: array + role: + type: string + roles: + items: + properties: + db: + type: string + role: + type: string + required: + - db + - role + type: object + type: array + required: + - db + - role + type: object + type: array + tls: + properties: + additionalCertificateDomains: + items: + type: string + type: array + ca: + description: CA corresponds to a ConfigMap containing an entry + for the CA certificate (ca.pem) used to validate the certificates + created already. + type: string + enabled: + description: DEPRECATED please enable TLS by setting `security.certsSecretPrefix` + or `security.tls.secretRef.prefix`. Enables TLS for this + resource. This will make the operator try to mount a Secret + with a defined name (-cert). This is only + used when enabling TLS on a MongoDB resource, and not on + the AppDB, where TLS is configured by setting `secretRef.Name`. + type: boolean + type: object + type: object + service: + description: DEPRECATED please use `spec.statefulSet.spec.serviceName` + to provide a custom service name. this is an optional service, it + will get the name "-service" in case not provided + type: string + shard: + properties: + additionalMongodConfig: + type: object + x-kubernetes-preserve-unknown-fields: true + agent: + properties: + logLevel: + type: string + maxLogFileDurationHours: + type: integer + startupOptions: + additionalProperties: + type: string + type: object + type: object + type: object + x-kubernetes-preserve-unknown-fields: true + shardCount: + type: integer + shardPodSpec: + properties: + persistence: + properties: + multiple: + properties: + data: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + journal: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + logs: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + single: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + podTemplate: + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + shardSpecificPodSpec: + description: ShardSpecificPodSpec allows you to provide a Statefulset + override per shard. + items: + properties: + persistence: + properties: + multiple: + properties: + data: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + journal: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + logs: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + single: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + podTemplate: + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + type: array + statefulSet: + description: StatefulSetConfiguration provides the statefulset override + for each of the cluster's statefulset if "StatefulSetConfiguration" + is specified at cluster level under "clusterSpecList" that takes + precedence over the global one + properties: + spec: + type: object + x-kubernetes-preserve-unknown-fields: true + required: + - spec + type: object + type: + enum: + - Standalone + - ReplicaSet + - ShardedCluster + type: string + version: + pattern: ^[0-9]+.[0-9]+.[0-9]+(-.+)?$|^$ + type: string + required: + - credentials + - type + - version + type: object + x-kubernetes-preserve-unknown-fields: true + status: + properties: + backup: + properties: + statusName: + type: string + required: + - statusName + type: object + configServerCount: + type: integer + lastTransition: + type: string + link: + type: string + members: + type: integer + message: + type: string + mongodsPerShardCount: + type: integer + mongosCount: + type: integer + observedGeneration: + format: int64 + type: integer + phase: + type: string + resourcesNotReady: + items: + description: ResourceNotReady describes the dependent resource which + is not ready yet + properties: + errors: + items: + properties: + message: + type: string + reason: + type: string + type: object + type: array + kind: + description: ResourceKind specifies a kind of a Kubernetes resource. + Used in status of a Custom Resource + type: string + message: + type: string + name: + type: string + required: + - kind + - name + type: object + type: array + shardCount: + type: integer + version: + type: string + warnings: + items: + type: string + type: array + required: + - phase + - version + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/mongodb.com_mongodbmulticluster.yaml b/config/crd/bases/mongodb.com_mongodbmulticluster.yaml new file mode 100644 index 000000000..242028c27 --- /dev/null +++ b/config/crd/bases/mongodb.com_mongodbmulticluster.yaml @@ -0,0 +1,754 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.10.0 + creationTimestamp: null + name: mongodbmulticluster.mongodb.com +spec: + group: mongodb.com + names: + kind: MongoDBMultiCluster + listKind: MongoDBMultiClusterList + plural: mongodbmulticluster + shortNames: + - mdbmc + singular: mongodbmulticluster + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Current state of the MongoDB deployment. + jsonPath: .status.phase + name: Phase + type: string + - description: The time since the MongoDBMultiCluster resource was created. + jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + properties: + additionalMongodConfig: + description: 'AdditionalMongodConfig is additional configuration that + can be passed to each data-bearing mongod at runtime. Uses the same + structure as the mongod configuration file: https://docs.mongodb.com/manual/reference/configuration-options/' + type: object + x-kubernetes-preserve-unknown-fields: true + agent: + properties: + logLevel: + type: string + maxLogFileDurationHours: + type: integer + startupOptions: + additionalProperties: + type: string + type: object + type: object + backup: + description: Backup contains configuration options for configuring + backup for this MongoDB resource + properties: + assignmentLabels: + description: Assignment Labels set in the Ops Manager + items: + type: string + type: array + autoTerminateOnDeletion: + description: AutoTerminateOnDeletion indicates if the Operator + should stop and terminate the Backup before the cleanup, when + the MongoDB CR is deleted + type: boolean + encryption: + description: Encryption settings + properties: + kmip: + description: Kmip corresponds to the KMIP configuration assigned + to the Ops Manager Project's configuration. + properties: + client: + description: KMIP Client configuration + properties: + clientCertificatePrefix: + description: 'A prefix used to construct KMIP client + certificate (and corresponding password) Secret + names. The names are generated using the following + pattern: KMIP Client Certificate (TLS Secret): --kmip-client KMIP Client Certificate Password: + --kmip-client-password + The expected key inside is called "password".' + type: string + type: object + required: + - client + type: object + type: object + mode: + enum: + - enabled + - disabled + - terminated + type: string + snapshotSchedule: + properties: + clusterCheckpointIntervalMin: + enum: + - 15 + - 30 + - 60 + type: integer + dailySnapshotRetentionDays: + description: Number of days to retain daily snapshots. Setting + 0 will disable this rule. + maximum: 365 + minimum: 0 + type: integer + fullIncrementalDayOfWeek: + description: Day of the week when Ops Manager takes a full + snapshot. This ensures a recent complete backup. Ops Manager + sets the default value to SUNDAY. + enum: + - SUNDAY + - MONDAY + - TUESDAY + - WEDNESDAY + - THURSDAY + - FRIDAY + - SATURDAY + type: string + monthlySnapshotRetentionMonths: + description: Number of months to retain weekly snapshots. + Setting 0 will disable this rule. + maximum: 36 + minimum: 0 + type: integer + pointInTimeWindowHours: + description: Number of hours in the past for which a point-in-time + snapshot can be created. + enum: + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 15 + - 30 + - 60 + - 90 + - 120 + - 180 + - 360 + type: integer + referenceHourOfDay: + description: Hour of the day to schedule snapshots using a + 24-hour clock, in UTC. + maximum: 23 + minimum: 0 + type: integer + referenceMinuteOfHour: + description: Minute of the hour to schedule snapshots, in + UTC. + maximum: 59 + minimum: 0 + type: integer + snapshotIntervalHours: + description: Number of hours between snapshots. + enum: + - 6 + - 8 + - 12 + - 24 + type: integer + snapshotRetentionDays: + description: Number of days to keep recent snapshots. + maximum: 365 + minimum: 1 + type: integer + weeklySnapshotRetentionWeeks: + description: Number of weeks to retain weekly snapshots. Setting + 0 will disable this rule + maximum: 365 + minimum: 0 + type: integer + type: object + type: object + cloudManager: + properties: + configMapRef: + properties: + name: + type: string + type: object + type: object + clusterDomain: + format: hostname + type: string + clusterSpecList: + items: + description: ClusterSpecItem is the mongodb multi-cluster spec that + is specific to a particular Kubernetes cluster, this maps to the + statefulset created in each cluster + properties: + clusterName: + description: ClusterName is name of the cluster where the MongoDB + Statefulset will be scheduled, the name should have a one + on one mapping with the service-account created in the central + cluster to talk to the workload clusters. + type: string + exposedExternally: + description: 'DEPRECATED: use ExternalAccessConfiguration instead' + type: boolean + externalAccess: + description: ExternalAccessConfiguration provides external access + configuration for Multi-Cluster. + properties: + externalDomain: + description: An external domain that is used for exposing + MongoDB to the outside world. + type: string + externalService: + description: Provides a way to override the default (NodePort) + Service + properties: + annotations: + additionalProperties: + type: string + description: A map of annotations that shall be added + to the externally available Service. + type: object + spec: + description: A wrapper for the Service spec object. + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + type: object + memberConfig: + description: MemberConfig + items: + properties: + priority: + type: string + tags: + additionalProperties: + type: string + type: object + votes: + type: integer + type: object + type: array + x-kubernetes-preserve-unknown-fields: true + members: + description: Amount of members for this MongoDB Replica Set + type: integer + service: + description: this is an optional service, it will get the name + "-service" in case not provided + type: string + statefulSet: + description: StatefulSetConfiguration holds the optional custom + StatefulSet that should be merged into the operator created + one. + properties: + spec: + type: object + x-kubernetes-preserve-unknown-fields: true + required: + - spec + type: object + required: + - members + type: object + type: array + connectivity: + properties: + replicaSetHorizons: + description: 'ReplicaSetHorizons holds list of maps of horizons + to be configured in each of MongoDB processes. Horizons map + horizon names to the node addresses for each process in the + replicaset, e.g.: [ { "internal": "my-rs-0.my-internal-domain.com:31843", + "external": "my-rs-0.my-external-domain.com:21467" }, { "internal": + "my-rs-1.my-internal-domain.com:31843", "external": "my-rs-1.my-external-domain.com:21467" + }, ... ] The key of each item in the map is an arbitrary, user-chosen + string that represents the name of the horizon. The value of + the item is the host and, optionally, the port that this mongod + node will be connected to from.' + items: + additionalProperties: + type: string + type: object + type: array + type: object + credentials: + description: Name of the Secret holding credentials information + type: string + duplicateServiceObjects: + description: 'In few service mesh options for ex: Istio, by default + we would need to duplicate the service objects created per pod in + all the clusters to enable DNS resolution. Users can however configure + their ServiceMesh with DNS proxy(https://istio.io/latest/docs/ops/configuration/traffic-management/dns-proxy/) + enabled in which case the operator doesn''t need to create the service + objects per cluster. This options tells the operator whether it + should create the service objects in all the clusters or not. By + default, if not specified the operator would create the duplicate + svc objects.' + type: boolean + exposedExternally: + description: 'DEPRECATED: use ExternalAccessConfiguration instead' + type: boolean + externalAccess: + description: ExternalAccessConfiguration provides external access + configuration. + properties: + externalDomain: + description: An external domain that is used for exposing MongoDB + to the outside world. + type: string + externalService: + description: Provides a way to override the default (NodePort) + Service + properties: + annotations: + additionalProperties: + type: string + description: A map of annotations that shall be added to the + externally available Service. + type: object + spec: + description: A wrapper for the Service spec object. + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + type: object + featureCompatibilityVersion: + type: string + logLevel: + enum: + - DEBUG + - INFO + - WARN + - ERROR + - FATAL + type: string + opsManager: + properties: + configMapRef: + properties: + name: + type: string + type: object + type: object + persistent: + type: boolean + prometheus: + description: Prometheus configurations. + properties: + metricsPath: + description: Indicates path to the metrics endpoint. + pattern: ^\/[a-z0-9]+$ + type: string + passwordSecretRef: + description: Name of a Secret containing a HTTP Basic Auth Password. + properties: + key: + description: Key is the key in the secret storing this password. + Defaults to "password" + type: string + name: + description: Name is the name of the secret storing this user's + password + type: string + required: + - name + type: object + port: + description: Port where metrics endpoint will bind to. Defaults + to 9216. + type: integer + tlsSecretKeyRef: + description: Name of a Secret (type kubernetes.io/tls) holding + the certificates to use in the Prometheus endpoint. + properties: + key: + description: Key is the key in the secret storing this password. + Defaults to "password" + type: string + name: + description: Name is the name of the secret storing this user's + password + type: string + required: + - name + type: object + username: + description: HTTP Basic Auth Username for metrics endpoint. + type: string + required: + - passwordSecretRef + - username + type: object + security: + properties: + authentication: + description: Authentication holds various authentication related + settings that affect this MongoDB resource. + properties: + agents: + description: Agents contains authentication configuration + properties for the agents + properties: + automationLdapGroupDN: + type: string + automationPasswordSecretRef: + description: SecretKeySelector selects a key of a Secret. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, + uid?' + type: string + optional: + description: Specify whether the Secret or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + automationUserName: + type: string + clientCertificateSecretRef: + type: object + x-kubernetes-preserve-unknown-fields: true + mode: + description: Mode is the desired Authentication mode that + the agents will use + type: string + required: + - mode + type: object + enabled: + type: boolean + ignoreUnknownUsers: + description: IgnoreUnknownUsers maps to the inverse of auth.authoritativeSet + type: boolean + internalCluster: + type: string + ldap: + description: LDAP Configuration + properties: + authzQueryTemplate: + type: string + bindQueryPasswordSecretRef: + properties: + name: + type: string + required: + - name + type: object + bindQueryUser: + type: string + caConfigMapRef: + description: Allows to point at a ConfigMap/key with a + CA file to mount on the Pod + properties: + key: + description: The key to select. + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, + uid?' + type: string + optional: + description: Specify whether the ConfigMap or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + servers: + items: + type: string + type: array + timeoutMS: + type: integer + transportSecurity: + enum: + - tls + - none + type: string + userCacheInvalidationInterval: + type: integer + userToDNMapping: + type: string + validateLDAPServerConfig: + type: boolean + type: object + modes: + items: + type: string + type: array + requireClientTLSAuthentication: + description: Clients should present valid TLS certificates + type: boolean + required: + - enabled + type: object + certsSecretPrefix: + type: string + roles: + items: + properties: + authenticationRestrictions: + items: + properties: + clientSource: + items: + type: string + type: array + serverAddress: + items: + type: string + type: array + type: object + type: array + db: + type: string + privileges: + items: + properties: + actions: + items: + type: string + type: array + resource: + properties: + cluster: + type: boolean + collection: + type: string + db: + type: string + type: object + required: + - actions + - resource + type: object + type: array + role: + type: string + roles: + items: + properties: + db: + type: string + role: + type: string + required: + - db + - role + type: object + type: array + required: + - db + - role + type: object + type: array + tls: + properties: + additionalCertificateDomains: + items: + type: string + type: array + ca: + description: CA corresponds to a ConfigMap containing an entry + for the CA certificate (ca.pem) used to validate the certificates + created already. + type: string + enabled: + description: DEPRECATED please enable TLS by setting `security.certsSecretPrefix` + or `security.tls.secretRef.prefix`. Enables TLS for this + resource. This will make the operator try to mount a Secret + with a defined name (-cert). This is only + used when enabling TLS on a MongoDB resource, and not on + the AppDB, where TLS is configured by setting `secretRef.Name`. + type: boolean + type: object + type: object + statefulSet: + description: StatefulSetConfiguration provides the statefulset override + for each of the cluster's statefulset if "StatefulSetConfiguration" + is specified at cluster level under "clusterSpecList" that takes + precedence over the global one + properties: + spec: + type: object + x-kubernetes-preserve-unknown-fields: true + required: + - spec + type: object + type: + enum: + - Standalone + - ReplicaSet + - ShardedCluster + type: string + version: + pattern: ^[0-9]+.[0-9]+.[0-9]+(-.+)?$|^$ + type: string + required: + - credentials + - type + - version + type: object + x-kubernetes-preserve-unknown-fields: true + status: + properties: + backup: + properties: + statusName: + type: string + required: + - statusName + type: object + clusterStatusList: + description: ClusterStatusList holds a list of clusterStatuses corresponding + to each cluster + properties: + clusterStatuses: + items: + description: ClusterStatusItem is the mongodb multi-cluster + spec that is specific to a particular Kubernetes cluster, + this maps to the statefulset created in each cluster + properties: + clusterName: + description: ClusterName is name of the cluster where the + MongoDB Statefulset will be scheduled, the name should + have a one on one mapping with the service-account created + in the central cluster to talk to the workload clusters. + type: string + lastTransition: + type: string + members: + type: integer + message: + type: string + observedGeneration: + format: int64 + type: integer + phase: + type: string + resourcesNotReady: + items: + description: ResourceNotReady describes the dependent + resource which is not ready yet + properties: + errors: + items: + properties: + message: + type: string + reason: + type: string + type: object + type: array + kind: + description: ResourceKind specifies a kind of a Kubernetes + resource. Used in status of a Custom Resource + type: string + message: + type: string + name: + type: string + required: + - kind + - name + type: object + type: array + warnings: + items: + type: string + type: array + required: + - phase + type: object + type: array + type: object + lastTransition: + type: string + link: + type: string + message: + type: string + observedGeneration: + format: int64 + type: integer + phase: + type: string + resourcesNotReady: + items: + description: ResourceNotReady describes the dependent resource which + is not ready yet + properties: + errors: + items: + properties: + message: + type: string + reason: + type: string + type: object + type: array + kind: + description: ResourceKind specifies a kind of a Kubernetes resource. + Used in status of a Custom Resource + type: string + message: + type: string + name: + type: string + required: + - kind + - name + type: object + type: array + version: + type: string + warnings: + items: + type: string + type: array + required: + - phase + - version + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/mongodb.com_mongodbusers.yaml b/config/crd/bases/mongodb.com_mongodbusers.yaml new file mode 100644 index 000000000..e3fcfb0fb --- /dev/null +++ b/config/crd/bases/mongodb.com_mongodbusers.yaml @@ -0,0 +1,161 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.10.0 + creationTimestamp: null + name: mongodbusers.mongodb.com +spec: + group: mongodb.com + names: + kind: MongoDBUser + listKind: MongoDBUserList + plural: mongodbusers + shortNames: + - mdbu + singular: mongodbuser + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: The current state of the MongoDB User. + jsonPath: .status.phase + name: Phase + type: string + - description: The time since the MongoDB User resource was created. + jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + properties: + connectionStringSecretName: + type: string + db: + type: string + mongodbResourceRef: + properties: + name: + type: string + namespace: + type: string + required: + - name + type: object + passwordSecretKeyRef: + description: 'SecretKeyRef is a reference to a value in a given secret + in the same namespace. Based on: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.15/#secretkeyselector-v1-core' + properties: + key: + type: string + name: + type: string + required: + - name + type: object + roles: + items: + properties: + db: + type: string + name: + type: string + required: + - db + - name + type: object + type: array + username: + type: string + required: + - db + - username + type: object + status: + properties: + db: + type: string + lastTransition: + type: string + message: + type: string + observedGeneration: + format: int64 + type: integer + phase: + type: string + project: + type: string + resourcesNotReady: + items: + description: ResourceNotReady describes the dependent resource which + is not ready yet + properties: + errors: + items: + properties: + message: + type: string + reason: + type: string + type: object + type: array + kind: + description: ResourceKind specifies a kind of a Kubernetes resource. + Used in status of a Custom Resource + type: string + message: + type: string + name: + type: string + required: + - kind + - name + type: object + type: array + roles: + items: + properties: + db: + type: string + name: + type: string + required: + - db + - name + type: object + type: array + username: + type: string + warnings: + items: + type: string + type: array + required: + - db + - phase + - project + - username + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/mongodb.com_opsmanagers.yaml b/config/crd/bases/mongodb.com_opsmanagers.yaml new file mode 100644 index 000000000..74dbdc510 --- /dev/null +++ b/config/crd/bases/mongodb.com_opsmanagers.yaml @@ -0,0 +1,1075 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.10.0 + creationTimestamp: null + name: opsmanagers.mongodb.com +spec: + group: mongodb.com + names: + kind: MongoDBOpsManager + listKind: MongoDBOpsManagerList + plural: opsmanagers + shortNames: + - om + singular: opsmanager + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: The number of replicas of MongoDBOpsManager. + jsonPath: .spec.replicas + name: Replicas + type: integer + - description: The version of MongoDBOpsManager. + jsonPath: .spec.version + name: Version + type: string + - description: The current state of the MongoDBOpsManager. + jsonPath: .status.opsManager.phase + name: State (OpsManager) + type: string + - description: The current state of the MongoDBOpsManager Application Database. + jsonPath: .status.applicationDatabase.phase + name: State (AppDB) + type: string + - description: The current state of the MongoDBOpsManager Backup Daemon. + jsonPath: .status.backup.phase + name: State (Backup) + type: string + - description: The time since the MongoDBOpsManager resource was created. + jsonPath: .metadata.creationTimestamp + name: Age + type: date + - description: Warnings. + jsonPath: .status.warnings + name: Warnings + type: string + name: v1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + properties: + adminCredentials: + description: 'AdminSecret is the secret for the first admin user to + create has the fields: "Username", "Password", "FirstName", "LastName"' + type: string + applicationDatabase: + properties: + additionalMongodConfig: + description: 'AdditionalMongodConfig is additional configuration + that can be passed to each data-bearing mongod at runtime. Uses + the same structure as the mongod configuration file: https://docs.mongodb.com/manual/reference/configuration-options/' + type: object + x-kubernetes-preserve-unknown-fields: true + agent: + description: specify startup flags for the AutomationAgent and + MonitoringAgent + properties: + logLevel: + type: string + maxLogFileDurationHours: + type: integer + startupOptions: + additionalProperties: + type: string + type: object + type: object + automationConfig: + description: AutomationConfigOverride holds any fields that will + be merged on top of the Automation Config that the operator + creates for the AppDB. Currently only the process.disabled field + is recognized. + properties: + processes: + items: + description: OverrideProcess contains fields that we can + override on the AutomationConfig processes. + properties: + disabled: + type: boolean + name: + type: string + required: + - disabled + - name + type: object + type: array + required: + - processes + type: object + cloudManager: + properties: + configMapRef: + properties: + name: + type: string + type: object + type: object + clusterDomain: + type: string + connectivity: + properties: + replicaSetHorizons: + description: 'ReplicaSetHorizons holds list of maps of horizons + to be configured in each of MongoDB processes. Horizons + map horizon names to the node addresses for each process + in the replicaset, e.g.: [ { "internal": "my-rs-0.my-internal-domain.com:31843", + "external": "my-rs-0.my-external-domain.com:21467" }, { + "internal": "my-rs-1.my-internal-domain.com:31843", "external": + "my-rs-1.my-external-domain.com:21467" }, ... ] The key + of each item in the map is an arbitrary, user-chosen string + that represents the name of the horizon. The value of the + item is the host and, optionally, the port that this mongod + node will be connected to from.' + items: + additionalProperties: + type: string + type: object + type: array + type: object + credentials: + description: Name of the Secret holding credentials information + type: string + featureCompatibilityVersion: + type: string + logLevel: + enum: + - DEBUG + - INFO + - WARN + - ERROR + - FATAL + type: string + memberConfig: + description: MemberConfig + items: + properties: + priority: + type: string + tags: + additionalProperties: + type: string + type: object + votes: + type: integer + type: object + type: array + members: + description: Amount of members for this MongoDB Replica Set + maximum: 50 + minimum: 3 + type: integer + monitoringAgent: + description: specify startup flags for just the MonitoringAgent. + These take precedence over the flags set in AutomationAgent + properties: + logLevel: + type: string + maxLogFileDurationHours: + type: integer + startupOptions: + additionalProperties: + type: string + type: object + type: object + opsManager: + properties: + configMapRef: + properties: + name: + type: string + type: object + type: object + passwordSecretKeyRef: + description: PasswordSecretKeyRef contains a reference to the + secret which contains the password for the mongodb-ops-manager + SCRAM-SHA user + properties: + key: + type: string + name: + type: string + required: + - name + type: object + podSpec: + properties: + persistence: + properties: + multiple: + properties: + data: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + journal: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + logs: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + single: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + podTemplate: + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + prometheus: + description: Enables Prometheus integration on the AppDB. + properties: + metricsPath: + description: Indicates path to the metrics endpoint. + pattern: ^\/[a-z0-9]+$ + type: string + passwordSecretRef: + description: Name of a Secret containing a HTTP Basic Auth + Password. + properties: + key: + description: Key is the key in the secret storing this + password. Defaults to "password" + type: string + name: + description: Name is the name of the secret storing this + user's password + type: string + required: + - name + type: object + port: + description: Port where metrics endpoint will bind to. Defaults + to 9216. + type: integer + tlsSecretKeyRef: + description: Name of a Secret (type kubernetes.io/tls) holding + the certificates to use in the Prometheus endpoint. + properties: + key: + description: Key is the key in the secret storing this + password. Defaults to "password" + type: string + name: + description: Name is the name of the secret storing this + user's password + type: string + required: + - name + type: object + username: + description: HTTP Basic Auth Username for metrics endpoint. + type: string + required: + - passwordSecretRef + - username + type: object + security: + properties: + authentication: + description: Authentication holds various authentication related + settings that affect this MongoDB resource. + properties: + agents: + description: Agents contains authentication configuration + properties for the agents + properties: + automationLdapGroupDN: + type: string + automationPasswordSecretRef: + description: SecretKeySelector selects a key of a + Secret. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + description: 'Name of the referent. More info: + https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, + uid?' + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + automationUserName: + type: string + clientCertificateSecretRef: + type: object + x-kubernetes-preserve-unknown-fields: true + mode: + description: Mode is the desired Authentication mode + that the agents will use + type: string + required: + - mode + type: object + enabled: + type: boolean + ignoreUnknownUsers: + description: IgnoreUnknownUsers maps to the inverse of + auth.authoritativeSet + type: boolean + internalCluster: + type: string + ldap: + description: LDAP Configuration + properties: + authzQueryTemplate: + type: string + bindQueryPasswordSecretRef: + properties: + name: + type: string + required: + - name + type: object + bindQueryUser: + type: string + caConfigMapRef: + description: Allows to point at a ConfigMap/key with + a CA file to mount on the Pod + properties: + key: + description: The key to select. + type: string + name: + description: 'Name of the referent. More info: + https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, + uid?' + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + servers: + items: + type: string + type: array + timeoutMS: + type: integer + transportSecurity: + enum: + - tls + - none + type: string + userCacheInvalidationInterval: + type: integer + userToDNMapping: + type: string + validateLDAPServerConfig: + type: boolean + type: object + modes: + items: + type: string + type: array + requireClientTLSAuthentication: + description: Clients should present valid TLS certificates + type: boolean + required: + - enabled + type: object + certsSecretPrefix: + type: string + roles: + items: + properties: + authenticationRestrictions: + items: + properties: + clientSource: + items: + type: string + type: array + serverAddress: + items: + type: string + type: array + type: object + type: array + db: + type: string + privileges: + items: + properties: + actions: + items: + type: string + type: array + resource: + properties: + cluster: + type: boolean + collection: + type: string + db: + type: string + type: object + required: + - actions + - resource + type: object + type: array + role: + type: string + roles: + items: + properties: + db: + type: string + role: + type: string + required: + - db + - role + type: object + type: array + required: + - db + - role + type: object + type: array + tls: + properties: + additionalCertificateDomains: + items: + type: string + type: array + ca: + description: CA corresponds to a ConfigMap containing + an entry for the CA certificate (ca.pem) used to validate + the certificates created already. + type: string + enabled: + description: DEPRECATED please enable TLS by setting `security.certsSecretPrefix` + or `security.tls.secretRef.prefix`. Enables TLS for + this resource. This will make the operator try to mount + a Secret with a defined name (-cert). + This is only used when enabling TLS on a MongoDB resource, + and not on the AppDB, where TLS is configured by setting + `secretRef.Name`. + type: boolean + type: object + type: object + service: + description: this is an optional service, it will get the name + "-service" in case not provided + type: string + type: + enum: + - Standalone + - ReplicaSet + - ShardedCluster + type: string + version: + pattern: ^[0-9]+.[0-9]+.[0-9]+(-.+)?$|^$ + type: string + required: + - version + type: object + backup: + description: Backup + properties: + assignmentLabels: + description: Assignment Labels set in the Ops Manager + items: + type: string + type: array + blockStores: + items: + description: DataStoreConfig is the description of the config + used to reference to database. Reused by Oplog and Block stores + Optionally references the user if the Mongodb is configured + with authentication + properties: + assignmentLabels: + description: Assignment Labels set in the Ops Manager + items: + type: string + type: array + mongodbResourceRef: + properties: + name: + type: string + namespace: + type: string + required: + - name + type: object + mongodbUserRef: + properties: + name: + type: string + required: + - name + type: object + name: + type: string + required: + - mongodbResourceRef + - name + type: object + type: array + enabled: + description: Enabled indicates if Backups will be enabled for + this Ops Manager. + type: boolean + encryption: + description: Encryption settings + properties: + kmip: + description: Kmip corresponds to the KMIP configuration assigned + to the Ops Manager Project's configuration. + properties: + server: + description: KMIP Server configuration + properties: + ca: + description: CA corresponds to a ConfigMap containing + an entry for the CA certificate (ca.pem) used for + KMIP authentication + type: string + url: + description: 'KMIP Server url in the following format: + hostname:port Valid examples are: 10.10.10.3:5696 + my-kmip-server.mycorp.com:5696 kmip-svc.svc.cluster.local:5696' + pattern: '[^\:]+:[0-9]{0,5}' + type: string + required: + - ca + - url + type: object + required: + - server + type: object + type: object + externalServiceEnabled: + type: boolean + fileSystemStores: + items: + properties: + name: + type: string + required: + - name + type: object + type: array + headDB: + description: HeadDB specifies configuration options for the HeadDB + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + jvmParameters: + items: + type: string + type: array + members: + description: Members indicate the number of backup daemon pods + to create. + minimum: 1 + type: integer + opLogStores: + description: OplogStoreConfigs describes the list of oplog store + configs used for backup + items: + description: DataStoreConfig is the description of the config + used to reference to database. Reused by Oplog and Block stores + Optionally references the user if the Mongodb is configured + with authentication + properties: + assignmentLabels: + description: Assignment Labels set in the Ops Manager + items: + type: string + type: array + mongodbResourceRef: + properties: + name: + type: string + namespace: + type: string + required: + - name + type: object + mongodbUserRef: + properties: + name: + type: string + required: + - name + type: object + name: + type: string + required: + - mongodbResourceRef + - name + type: object + type: array + queryableBackupSecretRef: + description: QueryableBackupSecretRef references the secret which + contains the pem file which is used for queryable backup. This + will be mounted into the Ops Manager pod. + properties: + name: + type: string + required: + - name + type: object + s3OpLogStores: + description: S3OplogStoreConfigs describes the list of s3 oplog + store configs used for backup. + items: + properties: + assignmentLabels: + description: Assignment Labels set in the Ops Manager + items: + type: string + type: array + customCertificate: + description: Set this to "true" when you have custom certificates + for your S3 buckets + type: boolean + irsaEnabled: + description: 'This is only set to "true" when user is running + in EKS and is using AWS IRSA to configure S3 snapshot + store. For more details refer this: https://aws.amazon.com/blogs/opensource/introducing-fine-grained-iam-roles-service-accounts/' + type: boolean + mongodbResourceRef: + properties: + name: + type: string + namespace: + type: string + required: + - name + type: object + mongodbUserRef: + properties: + name: + type: string + required: + - name + type: object + name: + type: string + pathStyleAccessEnabled: + type: boolean + s3BucketEndpoint: + type: string + s3BucketName: + type: string + s3RegionOverride: + type: string + s3SecretRef: + properties: + name: + type: string + required: + - name + type: object + required: + - name + - pathStyleAccessEnabled + - s3BucketEndpoint + - s3BucketName + - s3SecretRef + type: object + type: array + s3Stores: + items: + properties: + assignmentLabels: + description: Assignment Labels set in the Ops Manager + items: + type: string + type: array + customCertificate: + description: Set this to "true" when you have custom certificates + for your S3 buckets + type: boolean + irsaEnabled: + description: 'This is only set to "true" when user is running + in EKS and is using AWS IRSA to configure S3 snapshot + store. For more details refer this: https://aws.amazon.com/blogs/opensource/introducing-fine-grained-iam-roles-service-accounts/' + type: boolean + mongodbResourceRef: + properties: + name: + type: string + namespace: + type: string + required: + - name + type: object + mongodbUserRef: + properties: + name: + type: string + required: + - name + type: object + name: + type: string + pathStyleAccessEnabled: + type: boolean + s3BucketEndpoint: + type: string + s3BucketName: + type: string + s3RegionOverride: + type: string + s3SecretRef: + properties: + name: + type: string + required: + - name + type: object + required: + - name + - pathStyleAccessEnabled + - s3BucketEndpoint + - s3BucketName + - s3SecretRef + type: object + type: array + statefulSet: + description: StatefulSetConfiguration holds the optional custom + StatefulSet that should be merged into the operator created + one. + properties: + spec: + type: object + x-kubernetes-preserve-unknown-fields: true + required: + - spec + type: object + required: + - enabled + type: object + clusterDomain: + format: hostname + type: string + clusterName: + description: 'Deprecated: This has been replaced by the ClusterDomain + which should be used instead' + format: hostname + type: string + configuration: + additionalProperties: + type: string + description: The configuration properties passed to Ops Manager/Backup + Daemon + type: object + externalConnectivity: + description: MongoDBOpsManagerExternalConnectivity if sets allows + for the creation of a Service for accessing this Ops Manager resource + from outside the Kubernetes cluster. + properties: + annotations: + additionalProperties: + type: string + description: Annotations is a list of annotations to be directly + passed to the Service object. + type: object + externalTrafficPolicy: + description: ExternalTrafficPolicy mechanism to preserve the client + source IP. Only supported on GCE and Google Kubernetes Engine. + enum: + - Cluster + - Local + type: string + loadBalancerIP: + description: LoadBalancerIP IP that will be assigned to this LoadBalancer. + type: string + port: + description: Port in which this `Service` will listen to, this + applies to `NodePort`. + format: int32 + type: integer + type: + description: Type of the `Service` to be created. + enum: + - LoadBalancer + - NodePort + type: string + required: + - type + type: object + jvmParameters: + description: Custom JVM parameters passed to the Ops Manager JVM + items: + type: string + type: array + replicas: + minimum: 1 + type: integer + security: + description: Configure HTTPS. + properties: + certsSecretPrefix: + type: string + tls: + properties: + ca: + type: string + secretRef: + properties: + name: + type: string + required: + - name + type: object + type: object + type: object + statefulSet: + description: Configure custom StatefulSet configuration + properties: + spec: + type: object + x-kubernetes-preserve-unknown-fields: true + required: + - spec + type: object + version: + type: string + required: + - applicationDatabase + - version + type: object + status: + properties: + applicationDatabase: + properties: + backup: + properties: + statusName: + type: string + required: + - statusName + type: object + configServerCount: + type: integer + lastTransition: + type: string + link: + type: string + members: + type: integer + message: + type: string + mongodsPerShardCount: + type: integer + mongosCount: + type: integer + observedGeneration: + format: int64 + type: integer + phase: + type: string + resourcesNotReady: + items: + description: ResourceNotReady describes the dependent resource + which is not ready yet + properties: + errors: + items: + properties: + message: + type: string + reason: + type: string + type: object + type: array + kind: + description: ResourceKind specifies a kind of a Kubernetes + resource. Used in status of a Custom Resource + type: string + message: + type: string + name: + type: string + required: + - kind + - name + type: object + type: array + shardCount: + type: integer + version: + type: string + warnings: + items: + type: string + type: array + required: + - phase + - version + type: object + backup: + properties: + lastTransition: + type: string + message: + type: string + observedGeneration: + format: int64 + type: integer + phase: + type: string + resourcesNotReady: + items: + description: ResourceNotReady describes the dependent resource + which is not ready yet + properties: + errors: + items: + properties: + message: + type: string + reason: + type: string + type: object + type: array + kind: + description: ResourceKind specifies a kind of a Kubernetes + resource. Used in status of a Custom Resource + type: string + message: + type: string + name: + type: string + required: + - kind + - name + type: object + type: array + version: + type: string + warnings: + items: + type: string + type: array + required: + - phase + type: object + opsManager: + properties: + lastTransition: + type: string + message: + type: string + observedGeneration: + format: int64 + type: integer + phase: + type: string + replicas: + type: integer + resourcesNotReady: + items: + description: ResourceNotReady describes the dependent resource + which is not ready yet + properties: + errors: + items: + properties: + message: + type: string + reason: + type: string + type: object + type: array + kind: + description: ResourceKind specifies a kind of a Kubernetes + resource. Used in status of a Custom Resource + type: string + message: + type: string + name: + type: string + required: + - kind + - name + type: object + type: array + url: + type: string + version: + type: string + warnings: + items: + type: string + type: array + required: + - phase + type: object + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml new file mode 100644 index 000000000..4d3100512 --- /dev/null +++ b/config/crd/kustomization.yaml @@ -0,0 +1,13 @@ +# This kustomization.yaml is not intended to be run by itself, +# since it depends on service name and namespace that are out of this kustomize package. +# It should be run by config/default +resources: +- bases/mongodb.com_mongodb.yaml +- bases/mongodb.com_mongodbusers.yaml +- bases/mongodb.com_opsmanagers.yaml +- bases/mongodb.com_mongodbmulticluster.yaml +# +kubebuilder:scaffold:crdkustomizeresource + +# the following config is for teaching kustomize how to do kustomization for CRDs. +configurations: +- kustomizeconfig.yaml diff --git a/config/crd/kustomizeconfig.yaml b/config/crd/kustomizeconfig.yaml new file mode 100644 index 000000000..ec5c150a9 --- /dev/null +++ b/config/crd/kustomizeconfig.yaml @@ -0,0 +1,19 @@ +# This file is for teaching kustomize how to substitute name and namespace reference in CRD +nameReference: +- kind: Service + version: v1 + fieldSpecs: + - kind: CustomResourceDefinition + version: v1 + group: apiextensions.k8s.io + path: spec/conversion/webhook/clientConfig/service/name + +namespace: +- kind: CustomResourceDefinition + version: v1 + group: apiextensions.k8s.io + path: spec/conversion/webhook/clientConfig/service/namespace + create: false + +varReference: +- path: metadata/annotations diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml new file mode 100644 index 000000000..127077dcb --- /dev/null +++ b/config/default/kustomization.yaml @@ -0,0 +1,25 @@ +# Adds namespace to all resources. +# namespace: placeholder + +# Value of this field is prepended to the +# names of all resources, e.g. a deployment named +# "wordpress" becomes "alices-wordpress". +# Note that it should also match with the prefix (text before '-') of the namespace +# field above. +namePrefix: + +# Labels to add to all resources and selectors. +#commonLabels: +# someName: someValue + +bases: +- ../crd +- ../rbac +- ../manager +- ../scorecard + +# not used +patchesStrategicMerge: + +# the following config is for teaching kustomize how to do var substitution +vars: diff --git a/config/manager/kustomization.yaml b/config/manager/kustomization.yaml new file mode 100644 index 000000000..9dd294101 --- /dev/null +++ b/config/manager/kustomization.yaml @@ -0,0 +1,12 @@ +resources: +- manager.yaml + +generatorOptions: + disableNameSuffixHash: true + +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +images: +- name: controller + newName: mongodb-enterprise-operator + newTag: latest diff --git a/config/manifests/bases/mongodb-enterprise.clusterserviceversion.yaml b/config/manifests/bases/mongodb-enterprise.clusterserviceversion.yaml new file mode 100644 index 000000000..b1c37be8e --- /dev/null +++ b/config/manifests/bases/mongodb-enterprise.clusterserviceversion.yaml @@ -0,0 +1,440 @@ +apiVersion: operators.coreos.com/v1alpha1 +kind: ClusterServiceVersion +metadata: + annotations: + alm-examples: '[]' + capabilities: Deep Insights + categories: Database + certified: 'true' + containerImage: quay.io/mongodb/mongodb-enterprise-operator-ubi:1.20.0 + createdAt: '' + description: The MongoDB Enterprise Kubernetes Operator enables easy deploys of + MongoDB into Kubernetes clusters, using our management, monitoring and backup + platforms, Ops Manager and Cloud Manager. + operators.openshift.io/infrastructure-features: '["disconnected"]' + repository: https://github.com/mongodb/mongodb-enterprise-kubernetes + support: support@mongodb.com + name: mongodb-enterprise.v0.0.0 + namespace: placeholder +spec: + apiservicedefinitions: {} + customresourcedefinitions: + owned: + - description: MongoDB Deployment + displayName: MongoDB Deployment + kind: MongoDB + name: mongodb.mongodb.com + resources: + - kind: StatefulSet + name: StatefulSet holding the Pod with MongoDB + version: apps/v1 + - kind: Service + name: '' + version: v1 + specDescriptors: + - displayName: MongoDB Deployment Type + path: type + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:select:Standalone + - urn:alm:descriptor:com.tectonic.ui:select:ReplicaSet + - urn:alm:descriptor:com.tectonic.ui:select:ShardedCluster + - urn:alm:descriptor:com.tectonic.ui:fieldGroup:ClusterConfiguration + - description: Version of MongoDB to use. + displayName: MongoDB Version + path: version + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:text + - urn:alm:descriptor:com.tectonic.ui:fieldGroup:ClusterConfiguration + - description: In a Replica Set deployment type, specifies the amount of members. + displayName: Members of a Replica Set + path: members + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:podCount + - urn:alm:descriptor:com.tectonic.ui:fieldGroup:ClusterConfiguration + - displayName: Cloud/Ops Manager credentials + path: credentials + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:fieldGroup:OpsManagerConfig + - urn:alm:descriptor:io.kubernetes:Secret + - description: Project configuration for this deployment + displayName: Ops Manager project configuration + path: opsManager + - description: Name of the ConfigMap with the configuration for this project + displayName: Ops Manager Project Configuration + path: opsManager.configMapRef.name + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:fieldGroup:OpsManagerConfig + - urn:alm:descriptor:io.kubernetes:ConfigMap + - description: Enable Persistent Storage with Volume Claims + displayName: Persistent Storage + path: persistent + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:booleanSwitch + - urn:alm:descriptor:com.tectonic.ui:fieldGroup:ClusterConfiguration + - description: Optional. Name of a Kubernetes Cluster Domain. + displayName: Name of Kubernetes Cluster Domain + path: clusterDomain + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:text + - displayName: Enable TLS + path: security.tls.enabled + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:fieldGroup:security + - urn:alm:descriptor:com.tectonic.ui:booleanSwitch + - displayName: Custom CA Config Map + path: security.tls.ca + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:fieldGroup:security + - urn:alm:descriptor:io.kubernetes:ConfigMap + - displayName: Enable authentication + path: security.authentication.enabled + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:fieldGroup:Authentication + - urn:alm:descriptor:com.tectonic.ui:booleanSwitch + - displayName: Authentication Mode + path: security.authentication.modes + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:fieldGroup:Authentication + - urn:alm:descriptor:com.tectonic.ui:select:X509 + - urn:alm:descriptor:com.tectonic.ui:select:SCRAM + - urn:alm:descriptor:com.tectonic.ui:select:LDAP + - displayName: Authentication Mode used for Inter cluster communication + path: security.authentication.internalCluster + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:fieldGroup:Authentication + - urn:alm:descriptor:com.tectonic.ui:select:X509 + - urn:alm:descriptor:com.tectonic.ui:select:SCRAM + - urn:alm:descriptor:com.tectonic.ui:select:LDAP + - description: Number of Config Servers in Replica + displayName: Number of Config Servers + path: configServerCount + - description: Number of Shards in a Sharded Cluster + displayName: Number of Shards + path: shardCount + - description: Number of MongoDB Servers per Shard + displayName: Number of MongoDB Servers + path: mongodsPerShardCount + - description: Number of Mongo routers, in total, for the whole cluster + displayName: Number of Mongos + path: mongosCount + statusDescriptors: + - description: | + Phase the MongoDB Deployment is currently on. It can be any of Running, Pending, Failed. + displayName: Phase + path: phase + - description: | + Type describes the deployment type this MongoDB resource. Posible values + are Standalone, ReplicaSet or ShardedCluster. + displayName: Type + path: type + - description: | + Timestamp of last transition + displayName: Last Transition + path: lastTransition + - description: | + Current version of MongoDB + displayName: MongoDB Version + path: version + version: v1 + - description: MongoDB Multi Deployment + displayName: MongoDB Multi Deployment + kind: MongoDBMultiCluster + name: mongodbmulticluster.mongodb.com + resources: + - kind: StatefulSet + name: StatefulSet holding the Pod with MongoDB + version: apps/v1 + - kind: Service + name: '' + version: v1 + specDescriptors: + - displayName: MongoDB Deployment Type + path: type + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:select:ReplicaSet + - description: Version of MongoDB to use. + displayName: MongoDB Version + path: version + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:text + - urn:alm:descriptor:com.tectonic.ui:fieldGroup:ClusterConfiguration + - description: In a Replica Set deployment type, specifies the amount of members. + displayName: Members of a Replica Set + path: members + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:podCount + - urn:alm:descriptor:com.tectonic.ui:fieldGroup:ClusterConfiguration + - displayName: Cloud/Ops Manager credentials + path: credentials + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:fieldGroup:OpsManagerConfig + - urn:alm:descriptor:io.kubernetes:Secret + - description: Project configuration for this deployment + displayName: Ops Manager project configuration + path: opsManager + - description: Name of the ConfigMap with the configuration for this project + displayName: Ops Manager Project Configuration + path: opsManager.configMapRef.name + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:fieldGroup:OpsManagerConfig + - urn:alm:descriptor:io.kubernetes:ConfigMap + - description: Enable Persistent Storage with Volume Claims + displayName: Persistent Storage + path: persistent + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:booleanSwitch + - urn:alm:descriptor:com.tectonic.ui:fieldGroup:ClusterConfiguration + - description: Optional. Specify whether to duplicate service objects among + different Kubernetes clusters. + displayName: Duplicate Service Objects + path: duplicateServiceObjects + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:booleanSwitch + - urn:alm:descriptor:com.tectonic.ui:fieldGroup:ClusterConfiguration + - description: Optional. Name of a Kubernetes Cluster Domain. + displayName: Name of Kubernetes Cluster Domain + path: clusterDomain + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:text + - displayName: Enable TLS + path: security.tls.enabled + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:fieldGroup:security + - urn:alm:descriptor:com.tectonic.ui:booleanSwitch + - displayName: Custom CA Config Map + path: security.tls.ca + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:fieldGroup:security + - urn:alm:descriptor:io.kubernetes:ConfigMap + - displayName: Enable authentication + path: security.authentication.enabled + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:fieldGroup:Authentication + - urn:alm:descriptor:com.tectonic.ui:booleanSwitch + - displayName: Authentication Mode + path: security.authentication.modes + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:fieldGroup:Authentication + - urn:alm:descriptor:com.tectonic.ui:select:SCRAM + - description: Spec for each cluster that comprises MongoDB Replicaset + displayName: Cluster SpecList + path: clusterSpecList + statusDescriptors: + - description: | + Phase the MongoDB Deployment is currently on. It can be any of Running, Pending, Failed. + displayName: Phase + path: phase + version: v1 + - description: MongoDB x509 User + displayName: MongoDB User + kind: MongoDBUser + name: mongodbusers.mongodb.com + resources: + - kind: Secret + name: '' + version: v1 + specDescriptors: + - displayName: Name of the database user. + path: username + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:text + - displayName: Name of the database that stores usernames. + path: db + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:text + - displayName: Secret Name that user stores the user’s password. + path: passwordSecretKeyRef.name + x-descriptors: + - urn:alm:descriptor:io.kubernetes:Secret + - displayName: Name of the MongoDB resource to which this user is associated. + path: mongodbResourceRef.name + x-descriptors: + - urn:alm:descriptor:io.kubernetes:mongodb + - displayName: Database on which the role can act. + path: roles.db + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:text + - urn:alm:descriptor:com.tectonic.ui:arrayFieldGroup:Roles + - displayName: Name of the role to grant the database user. + path: roles.name + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:text + - urn:alm:descriptor:com.tectonic.ui:arrayFieldGroup:Roles + - description: MongoDB resource this user belongs to + displayName: MongoDB resource + path: mongodbResourceRef + - description: Roles this user will have + displayName: MongoDB roles + path: roles + statusDescriptors: + - description: | + The current state of the MongoDB User + displayName: State + path: phase + version: v1 + - description: MongoDB Ops Manager + displayName: MongoDB Ops Manager + kind: MongoDBOpsManager + name: opsmanagers.mongodb.com + resources: + - kind: StatefulSet + name: '' + version: apps/v1 + - kind: Service + name: '' + version: v1 + - kind: ConfigMap + name: '' + version: v1 + specDescriptors: + - displayName: The version of Ops Manager to deploy. + path: version + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:number + - urn:alm:descriptor:com.tectonic.ui:fieldGroup:OpsManagerConfiguration + - displayName: Number of Ops Manager instances. + path: replicas + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:number + - urn:alm:descriptor:com.tectonic.ui:fieldGroup:OpsManagerConfiguration + - displayName: Secret containing admin user credentials. + path: adminCredentials + x-descriptors: + - urn:alm:descriptor:io.kubernetes:Secret + - urn:alm:descriptor:com.tectonic.ui:fieldGroup:OpsManagerConfiguration + - displayName: Secret to enable TLS for Ops Manager allowing it to serve traffic + over HTTPS. + path: security.tls.secretRef.name + x-descriptors: + - urn:alm:descriptor:io.kubernetes:Secret + - urn:alm:descriptor:com.tectonic.ui:fieldGroup:OpsManagerConfiguration + - displayName: Number of ReplicaSet nodes for Application Database. + path: applicationDatabase.members + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:number + - urn:alm:descriptor:com.tectonic.ui:fieldGroup:ApplicationDatabase + - displayName: Secret containing the TLS certificate signed by known or custom + CA. + path: applicationDatabase.security.tls.secretRef.name + x-descriptors: + - urn:alm:descriptor:io.kubernetes:Secret + - urn:alm:descriptor:com.tectonic.ui:fieldGroup:ApplicationDatabase + - displayName: ConfigMap with CA for Custom TLS Certificate + path: applicationDatabase.security.tls.ca + x-descriptors: + - urn:alm:descriptor:io.kubernetes:ConfigMap + - urn:alm:descriptor:com.tectonic.ui:fieldGroup:ApplicationDatabase + - displayName: Enable Backup Infrastructure + path: backup.enabled + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:booleanSwitch + - urn:alm:descriptor:com.tectonic.ui:fieldGroup:BackupInfrastructure + - description: Application Database configuration + displayName: Application Database + path: applicationDatabase + - description: configuration + displayName: configuration + path: configuration + - description: Configures external connectivity + displayName: External Connectivity + path: externalConnectivity + statusDescriptors: + - description: | + The current state of the MongoDBOpsManager. + displayName: Phase + path: opsManager.phase + - description: Type of deployment + displayName: Type + path: type + version: v1 + description: | + The MongoDB Enterprise Kubernetes Operator enables easy deploys of MongoDB + into Kubernetes clusters, using our management, monitoring and backup + platforms, Ops Manager and Cloud Manager. + + ## Before You Start + + To start using the operator you''ll need an account in MongoDB Cloud Manager or + a MongoDB Ops Manager deployment. + + * [Create a Secret with your OpsManager API key](https://docs.mongodb.com/kubernetes-operator/stable/tutorial/create-operator-credentials/#procedure) + + * [Create a ConfigMap with your OpsManager project ID and URL](https://docs.mongodb.com/kubernetes-operator/stable/tutorial/create-project-using-configmap/) + + By installing this integration, you will be able to deploy MongoDB instances + with a single simple command. + + ## Required Parameters + + * `opsManager` or `cloudManager` - Enter the name of the ConfigMap containing project information + * `credentials` - Enter the name of the Secret containing your OpsManager credentials + * `type` - Enter MongoDB Deployment Types ("Standalone", "ReplicaSet", "ShardedCluster" + + ## Supported MongoDB Deployment Types ## + + * Standalone: An instance of mongod that is running as a single server and + not as part of a replica set, this is, it does not do any kind of + replication. + + * Replica Set: A replica set in MongoDB is a group of mongod processes that + maintain the same data set. Replica sets provide redundancy and high + availability, and are the basis for all production deployments. This section + introduces replication in MongoDB as well as the components and architecture + of replica sets. The section also provides tutorials for common tasks + related to replica sets. + + * Sharded Cluster: The set of nodes comprising a sharded MongoDB deployment. + A sharded cluster consists of config servers, shards, and one or more mongos + routing processes. Sharding is a A database architecture that partitions + data by key ranges and distributes the data among two or more database + instances. Sharding enables horizontal scaling. + + ## Requirements for deploying MongoDB OpsManager + + * In order to deploy resources of type MongoDB OpsManager, you will need to + create a secret containing the [credentials](https://docs.mongodb.com/kubernetes-operator/stable/tutorial/plan-om-resource/#om-rsrc-prereqs) + for the Global Owner user + + ## Security ## + + The operator can enable TLS for all traffic between servers and also between + clients and servers. Before enabling `security.tls.enabled` to `true` you + should create your certificates. or you can leave the operator to create all + the certificates for you. The operator ability to create certs is been + deprecated due to Kubernetes API changes. + + For more information, please read the official MongoDB + Kubernetes Operator [docs](https://docs.mongodb.com/kubernetes-operator/stable/). + displayName: MongoDB Enterprise Operator + icon: + - base64data: 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mrIHXO4A/U+GbMqu5Kcp5HLA+xl9UwbovQWCq/XeGvs4fQBS1FXiBjg4BIbzlL3m8e7O1t7+PdPo7wdVb3KbaWTEXAAADRxpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+Cjx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDQuNC4wLUV4aXYyIj4KIDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+CiAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIgogICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIKICAgIHhtbG5zOkdJTVA9Imh0dHA6Ly93d3cuZ2ltcC5vcmcveG1wLyIKICAgIHhtbG5zOnRpZmY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vdGlmZi8xLjAvIgogICAgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIgogICB4bXBNTTpEb2N1bWVudElEPSJnaW1wOmRvY2lkOmdpbXA6ZDk1YjhmMjctMWM0NS00YjU1LWEwZTMtNmNmMjM0Yzk1ZWVkIgogICB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOmVhMGY5MTI5LWJlMDItNDVjOS1iNGU4LTU3N2MxZTBiZGJhNyIKICAgeG1wTU06T3JpZ2luYWxEb2N1bWVudElEPSJ4bXAuZGlkOjcyNmY4ZGFlLTM4ZTYtNGQ4Ni1hNTI4LWM0NTc4ZGE4ODA0NSIKICAgZGM6Rm9ybWF0PSJpbWFnZS9wbmciCiAgIEdJTVA6QVBJPSIyLjAiCiAgIEdJTVA6UGxhdGZvcm09Ik1hYyBPUyIKICAgR0lNUDpUaW1lU3RhbXA9IjE2MzQ4MzgwMTYyMTQ2MTMiCiAgIEdJTVA6VmVyc2lvbj0iMi4xMC4yNCIKICAgdGlmZjpPcmllbnRhdGlvbj0iMSIKICAgeG1wOkNyZWF0b3JUb29sPSJHSU1QIDIuMTAiPgogICA8eG1wTU06SGlzdG9yeT4KICAgIDxyZGY6U2VxPgogICAgIDxyZGY6bGkKICAgICAgc3RFdnQ6YWN0aW9uPSJzYXZlZCIKICAgICAgc3RFdnQ6Y2hhbmdlZD0iLyIKICAgICAgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDo1YWNhZmVhMC0xZmY5LTRiMmUtYmY0NC02NTM3MzYwMGQzNjEiCiAgICAgIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkdpbXAgMi4xMCAoTWFjIE9TKSIKICAgICAgc3RFdnQ6d2hlbj0iMjAyMS0xMC0yMVQxODo0MDoxNiswMTowMCIvPgogICAgPC9yZGY6U2VxPgogICA8L3htcE1NOkhpc3Rvcnk+CiAgPC9yZGY6RGVzY3JpcHRpb24+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgCjw/eHBhY2tldCBlbmQ9InciPz6528V0AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAB3RJTUUH5QoVESgQ+iToFAAAA8xJREFUeNrlW01PU0EUPTPV+oqb4h+wENYKXbmzsjLEKPAHwB1xQ6N7adiboBtrSAT5AaQmBpuYSN25MS17k5Zf0MemFGznungttCkf782bmTels2w6mbnnnnPv3DvzYrBhrMytIT01gz9/f5temkVv/NMUwKsg1MFEGvlizeTy3ALj9zuuGAf4T2QzydEBACwHINXzwwSOE29N7iAWqe7BsoOYsEdITx2ZigcsIupnzqh/8SC0/6Wx+aNy8yTg6X7rWsfEbu96/71JAGQzyY7n/Rg2AcZ3dQdFswA0Exs+je8KYUZ3UDQXA1bmlgFsScwkMFrEx++F4QXgPN/LaZpQR6IxiY2SO6QSGMj3Qd00jpPE5+FkgDz1B3kAMYt8sTQ8AGQzSTTHyqG83z+qcBpplVLQK4Hm2KpC473U2BzLDgcDwgY+QwFRIwP4knLjuwFRIQv0MGB5PgnntKwFAMUs0MMA53Rem/Ge25I4ufvCXgkQVrVXsSSW7JTAq7lpCJQNnK4IEJNhW2jqGdDGsrH6QrB5GyXwWMKXLoi5gdnL8dwuCXjRvy4xs0vjVGDonMa9MNlALQPiJxlJOcvruOlM2yMBzuQ3Q3Al44BFADA8lJ9LrtSKnD2wBwAhe/hhIVIZpWxiQJgG5qHkohYBoPP4q6tks2Qfh1GBzu3xhWQckM0eWgAIfprrBE+SN4LZBACTNIQzF4KO5EAnmxgQwhtckj2WMeBA8gARpqQ9sAcAAfnrbLk4QGBUsQcAHmIzXFLLrbZFDMgXS1KZoN2W1DHVwj6iUH8O4FQKPCcWc3t6AkGCTin0dpUDQPhq6OREgNixD4BmvBBYBlKNTaqpuChVD8B2wQWj98EnOrVA3hf4YHExJLb1l3FUsBeAfLEG0Bef//Y8H28FqSW2VT2p1VgNUi5QLKC4z1qCqoBYt78fkC/WfMWCwMUM21H5oFrzA4n4xrUt724xQy0fxRRVkd/LKQ0lWgHYLrgAvfQXN1vXSYAAmlUeS7VH63yxBMIVUvDdB1jX8S2BmZbYp70scNkRmXtXaQkOXN4b3FJNfbMAAEDzzoLcFRhV4TReaztOGAPAiwdPLgDh8OqUR7M6XoiaB6CbGtts4cLzwbtv1N8Z7hiv+Rsi823xzb0KRB8T7gMA3jxj59dcZoz3snBUY+VpCmD7nautXGcva2Aog8Siqa/Hov1sbuAxJZXgHC/o1Hz0Ehgsmn71/FIxaXz0AAwS8sj0ihYAcBb5CVJ9weFnwLnR1K6PHgC9FyJsFCVwq+9afAQlIITbnxXMjv+6222dh4/VtAAAAABJRU5ErkJggg== + mediatype: image/png + install: + spec: + deployments: + strategy: '' + installModes: + - supported: true + type: OwnNamespace + - supported: true + type: SingleNamespace + - supported: false + type: MultiNamespace + - supported: true + type: AllNamespaces + keywords: + - mongodb + - databases + - nosql + links: + - name: Documentation + url: https://docs.opsmanager.mongodb.com/current/tutorial/install-k8s-operator/index.html + maintainers: + - email: support@mongodb.com + name: MongoDB, Inc + maturity: stable + minKubeVersion: 1.24.0 + provider: + name: MongoDB, Inc + replaces: mongodb-enterprise.v1.19.1 + version: 0.0.0 diff --git a/config/manifests/kustomization.yaml b/config/manifests/kustomization.yaml new file mode 100644 index 000000000..ec2c16556 --- /dev/null +++ b/config/manifests/kustomization.yaml @@ -0,0 +1,3 @@ +resources: +- ../default +- ../samples diff --git a/config/rbac/appdb_role.yaml b/config/rbac/appdb_role.yaml new file mode 100644 index 000000000..2914af913 --- /dev/null +++ b/config/rbac/appdb_role.yaml @@ -0,0 +1,19 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: mongodb-enterprise-appdb +rules: + - apiGroups: + - "" + resources: + - secrets + verbs: + - get + - apiGroups: + - "" + resources: + - pods + verbs: + - patch + - delete + - get diff --git a/config/rbac/appdb_role_binding.yaml b/config/rbac/appdb_role_binding.yaml new file mode 100644 index 000000000..462d2c43a --- /dev/null +++ b/config/rbac/appdb_role_binding.yaml @@ -0,0 +1,11 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: mongodb-enterprise-appdb +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: mongodb-enterprise-appdb +subjects: +- kind: ServiceAccount + name: mongodb-enterprise-appdb diff --git a/config/rbac/appdb_sa.yaml b/config/rbac/appdb_sa.yaml new file mode 100644 index 000000000..a88d0deda --- /dev/null +++ b/config/rbac/appdb_sa.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: mongodb-enterprise-appdb + namespace: placeholder + diff --git a/config/rbac/database_sa.yaml b/config/rbac/database_sa.yaml new file mode 100644 index 000000000..b773af6a2 --- /dev/null +++ b/config/rbac/database_sa.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: mongodb-enterprise-database-pods + namespace: placeholder diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml new file mode 100644 index 000000000..ad218334c --- /dev/null +++ b/config/rbac/kustomization.yaml @@ -0,0 +1,23 @@ +resources: +- role.yaml +- role_binding.yaml +- database_sa.yaml +- appdb_sa.yaml +- appdb_role.yaml +- appdb_role_binding.yaml +- om_sa.yaml + + +# The following roles won't be added to the CSV, only rbac: annotations +# in code. They exist in the operator-sdk project that's created from +# scratch. +# +# Leaving these files commented out because we might need them +# in the future. +# +# - mongodb_editor_role.yaml +# - mongodb_viewer_role.yaml +# - mongodbopsmanager_viewer_role.yaml +# - mongodbopsmanager_editor_role.yaml +# - mongodbusers_editor_role.yaml +# - mongodbusers_viewer_role.yaml diff --git a/config/rbac/mongodb_editor_role.yaml b/config/rbac/mongodb_editor_role.yaml new file mode 100644 index 000000000..bee6bedfd --- /dev/null +++ b/config/rbac/mongodb_editor_role.yaml @@ -0,0 +1,24 @@ +# permissions for end users to edit mongodbs. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: mongodb-editor-role +rules: +- apiGroups: + - mongodb.com + resources: + - mongodb + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - mongodb.com + resources: + - mongodb/status + verbs: + - get diff --git a/config/rbac/mongodb_viewer_role.yaml b/config/rbac/mongodb_viewer_role.yaml new file mode 100644 index 000000000..22d4e4d09 --- /dev/null +++ b/config/rbac/mongodb_viewer_role.yaml @@ -0,0 +1,20 @@ +# permissions for end users to view mongdbs. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: mongodb-viewer-role +rules: +- apiGroups: + - mongodb.com + resources: + - mongodb + verbs: + - get + - list + - watch +- apiGroups: + - mongodb.com + resources: + - mongodb/status + verbs: + - get diff --git a/config/rbac/mongodbopsmanager_editor_role.yaml b/config/rbac/mongodbopsmanager_editor_role.yaml new file mode 100644 index 000000000..399493cac --- /dev/null +++ b/config/rbac/mongodbopsmanager_editor_role.yaml @@ -0,0 +1,24 @@ +# permissions for end users to edit mongodbopsmanagers. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: mongodbopsmanager-editor-role +rules: +- apiGroups: + - mongodb.com + resources: + - opsmanagers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - mongodb.com + resources: + - opsmanagers/status + verbs: + - get diff --git a/config/rbac/mongodbopsmanager_viewer_role.yaml b/config/rbac/mongodbopsmanager_viewer_role.yaml new file mode 100644 index 000000000..a9e40fbac --- /dev/null +++ b/config/rbac/mongodbopsmanager_viewer_role.yaml @@ -0,0 +1,20 @@ +# permissions for end users to view mongodbopsmanagers. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: mongodbopsmanager-viewer-role +rules: +- apiGroups: + - mongodb.com + resources: + - opsmanagers + verbs: + - get + - list + - watch +- apiGroups: + - mongodb.com + resources: + - opsmanagers/status + verbs: + - get diff --git a/config/rbac/mongodbusers_editor_role.yaml b/config/rbac/mongodbusers_editor_role.yaml new file mode 100644 index 000000000..cbe2f00f7 --- /dev/null +++ b/config/rbac/mongodbusers_editor_role.yaml @@ -0,0 +1,24 @@ +# permissions for end users to edit mongodbusers. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: mongodbusers-editor-role +rules: +- apiGroups: + - mongodb.com + resources: + - mongodbusers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - mongodb.com + resources: + - mongodbusers/status + verbs: + - get diff --git a/config/rbac/mongodbusers_viewer_role.yaml b/config/rbac/mongodbusers_viewer_role.yaml new file mode 100644 index 000000000..5c1f8df55 --- /dev/null +++ b/config/rbac/mongodbusers_viewer_role.yaml @@ -0,0 +1,20 @@ +# permissions for end users to view mongdbusers. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: mongodbusers-viewer-role +rules: +- apiGroups: + - mongodb.com + resources: + - mongodbusers + verbs: + - get + - list + - watch +- apiGroups: + - mongodb.com + resources: + - mongodbusers/status + verbs: + - get diff --git a/config/rbac/om_sa.yaml b/config/rbac/om_sa.yaml new file mode 100644 index 000000000..a8f37215c --- /dev/null +++ b/config/rbac/om_sa.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: mongodb-enterprise-ops-manager + namespace: placeholder diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml new file mode 100644 index 000000000..efa851c97 --- /dev/null +++ b/config/rbac/role.yaml @@ -0,0 +1,113 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + creationTimestamp: null + name: manager-role +rules: +- apiGroups: + - admissionregistration.k8s.io + resources: + - validatingwebhookconfigurations + verbs: + - create + - delete + - get + - update +- apiGroups: + - certificates.k8s.io + resources: + - certificatesigningrequests + verbs: + - create + - get + - list + - watch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + creationTimestamp: null + name: manager-role + namespace: placeholder +rules: +- apiGroups: + - apps + resources: + - statefulsets + verbs: + - create + - delete + - get + - list + - update + - watch +- apiGroups: + - "" + resources: + - configmaps + - secrets + verbs: + - create + - delete + - get + - list + - update + - watch +- apiGroups: + - "" + resources: + - namespaces + verbs: + - list + - watch +- apiGroups: + - "" + resources: + - pods + verbs: + - get + - list + - watch +- apiGroups: + - "" + resources: + - services + verbs: + - create + - get + - list + - update + - watch +- apiGroups: + - mongodb.com + resources: + - mongodb + - mongodb/finalizers + - mongodb/status + verbs: + - '*' +- apiGroups: + - mongodb.com + resources: + - mongodbmulticluster + - mongodbmulticluster/finalizers + - mongodbmulticluster/status + verbs: + - '*' +- apiGroups: + - mongodb.com + resources: + - mongodbusers + - mongodbusers/finalizers + - mongodbusers/status + verbs: + - '*' +- apiGroups: + - mongodb.com + resources: + - opsmanagers + - opsmanagers/finalizers + - opsmanagers/status + verbs: + - '*' diff --git a/config/rbac/role_binding.yaml b/config/rbac/role_binding.yaml new file mode 100644 index 000000000..ae3cebcae --- /dev/null +++ b/config/rbac/role_binding.yaml @@ -0,0 +1,24 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: manager-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: manager-role +subjects: +- kind: ServiceAccount + name: mongodb-enterprise-operator + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: manager-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: manager-role +subjects: +- kind: ServiceAccount + name: mongodb-enterprise-operator diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml new file mode 100644 index 000000000..3f2f94737 --- /dev/null +++ b/config/samples/kustomization.yaml @@ -0,0 +1,6 @@ +resources: +- mongodb-replicaset.yaml +- mongodb-sharded.yaml +- mongodb-user.yaml +- mongodb-om.yaml +- mongodb-multi.yaml diff --git a/config/samples/mongodb-multi.yaml b/config/samples/mongodb-multi.yaml new file mode 100644 index 000000000..90bcfd38d --- /dev/null +++ b/config/samples/mongodb-multi.yaml @@ -0,0 +1,20 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBMultiCluster +metadata: + name: multi-replica-set +spec: + version: 4.4.0-ent + type: ReplicaSet + persistent: true + duplicateServiceObjects: false + credentials: my-credentials + opsManager: + configMapRef: + name: my-project + clusterSpecList: + - clusterName: e2e.cluster1.mongokubernetes.com + members: 2 + - clusterName: e2e.cluster2.mongokubernetes.com + members: 1 + - clusterName: e2e.cluster3.mongokubernetes.com + members: 2 diff --git a/config/samples/mongodb-om.yaml b/config/samples/mongodb-om.yaml new file mode 100644 index 000000000..964a58161 --- /dev/null +++ b/config/samples/mongodb-om.yaml @@ -0,0 +1,15 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: ops-manager +spec: + version: 6.0.3 + adminCredentials: ops-manager-admin + configuration: + mms.fromEmailAddr: admin@thecompany.com + externalConnectivity: + type: LoadBalancer + applicationDatabase: + members: 3 + podSpec: + cpu: 1 diff --git a/config/samples/mongodb-replicaset.yaml b/config/samples/mongodb-replicaset.yaml new file mode 100644 index 000000000..19c9839ed --- /dev/null +++ b/config/samples/mongodb-replicaset.yaml @@ -0,0 +1,13 @@ +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: my-replica-set +spec: + credentials: my-credentials + members: 3 + opsManager: + configMapRef: + name: my-project + type: ReplicaSet + version: 4.4.0-ent + persistent: true diff --git a/config/samples/mongodb-sharded.yaml b/config/samples/mongodb-sharded.yaml new file mode 100644 index 000000000..4ae92fc4e --- /dev/null +++ b/config/samples/mongodb-sharded.yaml @@ -0,0 +1,16 @@ +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: sample-sharded-cluster +spec: + version: 4.4.0-ent + type: ShardedCluster + configServerCount: 3 + credentials: my-credentials + mongodsPerShardCount: 3 + mongosCount: 2 + persistent: true + opsManager: + configMapRef: + name: my-project + shardCount: 1 diff --git a/config/samples/mongodb-user.yaml b/config/samples/mongodb-user.yaml new file mode 100644 index 000000000..0c8eadeeb --- /dev/null +++ b/config/samples/mongodb-user.yaml @@ -0,0 +1,14 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBUser +metadata: + name: my-replica-set-x509-user +spec: + db: $external + mongodbResourceRef: + name: my-replica-set + roles: + - db: admin + name: dbOwner + username: CN=my-replica-set-x509-user,OU=cloud,O=MongoDB,L=New York,ST=New York,C=US +status: + phase: Updated diff --git a/config/scorecard/bases/config.yaml b/config/scorecard/bases/config.yaml new file mode 100644 index 000000000..c77047841 --- /dev/null +++ b/config/scorecard/bases/config.yaml @@ -0,0 +1,7 @@ +apiVersion: scorecard.operatorframework.io/v1alpha3 +kind: Configuration +metadata: + name: config +stages: +- parallel: true + tests: [] diff --git a/config/scorecard/kustomization.yaml b/config/scorecard/kustomization.yaml new file mode 100644 index 000000000..d73509ee7 --- /dev/null +++ b/config/scorecard/kustomization.yaml @@ -0,0 +1,16 @@ +resources: +- bases/config.yaml +patchesJson6902: +- path: patches/basic.config.yaml + target: + group: scorecard.operatorframework.io + version: v1alpha3 + kind: Configuration + name: config +- path: patches/olm.config.yaml + target: + group: scorecard.operatorframework.io + version: v1alpha3 + kind: Configuration + name: config +# +kubebuilder:scaffold:patchesJson6902 diff --git a/config/scorecard/patches/basic.config.yaml b/config/scorecard/patches/basic.config.yaml new file mode 100644 index 000000000..c04db317d --- /dev/null +++ b/config/scorecard/patches/basic.config.yaml @@ -0,0 +1,10 @@ +- op: add + path: /stages/0/tests/- + value: + entrypoint: + - scorecard-test + - basic-check-spec + image: quay.io/operator-framework/scorecard-test:v1.12.0 + labels: + suite: basic + test: basic-check-spec-test diff --git a/config/scorecard/patches/olm.config.yaml b/config/scorecard/patches/olm.config.yaml new file mode 100644 index 000000000..122f70310 --- /dev/null +++ b/config/scorecard/patches/olm.config.yaml @@ -0,0 +1,50 @@ +- op: add + path: /stages/0/tests/- + value: + entrypoint: + - scorecard-test + - olm-bundle-validation + image: quay.io/operator-framework/scorecard-test:v1.12.0 + labels: + suite: olm + test: olm-bundle-validation-test +- op: add + path: /stages/0/tests/- + value: + entrypoint: + - scorecard-test + - olm-crds-have-validation + image: quay.io/operator-framework/scorecard-test:v1.12.0 + labels: + suite: olm + test: olm-crds-have-validation-test +- op: add + path: /stages/0/tests/- + value: + entrypoint: + - scorecard-test + - olm-crds-have-resources + image: quay.io/operator-framework/scorecard-test:v1.12.0 + labels: + suite: olm + test: olm-crds-have-resources-test +- op: add + path: /stages/0/tests/- + value: + entrypoint: + - scorecard-test + - olm-spec-descriptors + image: quay.io/operator-framework/scorecard-test:v1.12.0 + labels: + suite: olm + test: olm-spec-descriptors-test +- op: add + path: /stages/0/tests/- + value: + entrypoint: + - scorecard-test + - olm-status-descriptors + image: quay.io/operator-framework/scorecard-test:v1.12.0 + labels: + suite: olm + test: olm-status-descriptors-test diff --git a/controllers/controller.go b/controllers/controller.go new file mode 100644 index 000000000..bb9583811 --- /dev/null +++ b/controllers/controller.go @@ -0,0 +1,101 @@ +package controllers + +import ( + "strings" + + "sigs.k8s.io/controller-runtime/pkg/cluster" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + mdbmultiv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdbmulti" + omv1 "github.com/10gen/ops-manager-kubernetes/api/v1/om" + "github.com/10gen/ops-manager-kubernetes/api/v1/user" + "github.com/10gen/ops-manager-kubernetes/controllers/operator" + "sigs.k8s.io/controller-runtime/pkg/manager" +) + +var crdFuncMap map[string][]func(manager.Manager) error +var crdMultiFuncMap map[string][]func(manager.Manager, map[string]cluster.Cluster) error + +var ( + mdb = &mdbv1.MongoDB{} + mdbu = &user.MongoDBUser{} + om = &omv1.MongoDBOpsManager{} + mdbmulti = &mdbmultiv1.MongoDBMultiCluster{} +) + +func init() { + crdFuncMap = buildCrdFunctionMap() + crdMultiFuncMap = buildCrdMultiFunctionMap() +} + +// buildCrdFunctionMap creates a map which maps the name of the Custom +// Resource Definition to a function which adds the corresponding function +// to a manager.Manager for single cluster reconcilers +func buildCrdFunctionMap() map[string][]func(manager.Manager) error { + return map[string][]func(manager.Manager) error{ + strings.ToLower(mdb.GetPlural()): { + operator.AddStandaloneController, + operator.AddReplicaSetController, + operator.AddShardedClusterController, + mdb.AddValidationToManager, + }, + strings.ToLower(om.GetPlural()): { + operator.AddOpsManagerController, + om.AddValidationToManager, + }, + } +} + +// buildCrdMultiFunctionMap create a map which maps the name of the Custom +// Resource Definition to a function which adds the corresponding function +// to a manager.Manager and slice of cluster objects for single multi cluster reconcilers +func buildCrdMultiFunctionMap() map[string][]func(manager.Manager, map[string]cluster.Cluster) error { + return map[string][]func(manager.Manager, map[string]cluster.Cluster) error{ + strings.ToLower(mdbmulti.GetPlural()): { + operator.AddMultiReplicaSetController, + mdbmulti.AddValidationToManager, + }, + strings.ToLower(mdbu.GetPlural()): { + operator.AddMongoDBUserController, + }, + } +} + +// getCRDsToWatch returns the CRDs which the operator will register +// and recognize. It will default to all the CRDs we have. +func getCRDsToWatch(watchCRDStr string) []string { + defaultCRDstoWatch := []string{ + strings.ToLower(mdb.GetPlural()), + strings.ToLower(mdbu.GetPlural()), + strings.ToLower(om.GetPlural()), + } + if watchCRDStr == "" { + return defaultCRDstoWatch + } + return strings.Split(watchCRDStr, ",") +} + +// AddToManager adds all Controllers to the Manager +func AddToManager(m manager.Manager, crdsToWatchStr string, c map[string]cluster.Cluster) ([]string, error) { + crdsToWatch := getCRDsToWatch(crdsToWatchStr) + var addSingleToManagerFuncs []func(manager.Manager) error + var addMultiToManagerFuncs []func(manager.Manager, map[string]cluster.Cluster) error + + for _, ctr := range crdsToWatch { + addSingleToManagerFuncs = append(addSingleToManagerFuncs, crdFuncMap[strings.Trim(strings.ToLower(ctr), " ")]...) + addMultiToManagerFuncs = append(addMultiToManagerFuncs, crdMultiFuncMap[strings.Trim(strings.ToLower(ctr), " ")]...) + } + + for _, f := range addSingleToManagerFuncs { + if err := f(m); err != nil { + return []string{}, err + } + } + + for _, f := range addMultiToManagerFuncs { + if err := f(m, c); err != nil { + return []string{}, err + } + } + return crdsToWatch, nil +} diff --git a/controllers/controller_test.go b/controllers/controller_test.go new file mode 100644 index 000000000..8eb961040 --- /dev/null +++ b/controllers/controller_test.go @@ -0,0 +1,44 @@ +package controllers + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetCRDsToWatch(t *testing.T) { + tests := []struct { + name string + watchCRDsString string + expected []string + }{ + { + "All 3 CRDs", + "mongodb,mongodbusers,opsmanagers", + []string{"mongodb", "mongodbusers", "opsmanagers"}, + }, + { + "2 of 3 CRDs", + "mongodb,opsmanagers", + []string{"mongodb", "opsmanagers"}, + }, + { + "1 of 3 CRDs", + "opsmanagers", + []string{"opsmanagers"}, + }, + { + "The Empty String", + "", + []string{"mongodb", "mongodbusers", "opsmanagers"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := getCRDsToWatch(tt.watchCRDsString) + assert.True(t, reflect.DeepEqual(actual, tt.expected), "getCRDsToWatch() = %v, expected %v", actual, tt.expected) + }) + } +} diff --git a/controllers/om/agent.go b/controllers/om/agent.go new file mode 100644 index 000000000..aa6fd2995 --- /dev/null +++ b/controllers/om/agent.go @@ -0,0 +1,57 @@ +package om + +import ( + "strings" + "time" + + "go.uber.org/zap" +) + +// Checks if the agents have registered. + +type automationAgentStatusResponse struct { + OMPaginated + AutomationAgents []AgentStatus `json:"results"` +} + +type AgentStatus struct { + ConfCount int `json:"confCount"` + Hostname string `json:"hostname"` + LastConf string `json:"lastConf"` + StateName string `json:"stateName"` + TypeName string `json:"typeName"` +} + +var _ Paginated = automationAgentStatusResponse{} + +// IsRegistered will return true if this given agent has `hostname_prefix` as a +// prefix. This is needed to check if the given agent has registered. +func (agent AgentStatus) IsRegistered(hostnamePrefix string, log *zap.SugaredLogger) bool { + lastPing, err := time.Parse(time.RFC3339, agent.LastConf) + if err != nil { + log.Error("Wrong format for lastConf field: expected UTC format but the value is " + agent.LastConf) + return false + } + if strings.HasPrefix(agent.Hostname, hostnamePrefix) { + // Any pings earlier than 1 minute ago are signs that agents are in trouble, so we cannot consider them as + // registered (may be we should decrease this to ~5-10 seconds?) + if lastPing.Add(time.Minute).Before(time.Now()) { + log.Debugw("Agent is registered but its last ping was more than 1 minute ago", "ping", + lastPing, "hostname", agent.Hostname) + return false + } + log.Debugw("Agent is already registered", "hostname", agent.Hostname) + return true + } + + return false +} + +// Results is needed to fulfil the Paginated interface +func (aar automationAgentStatusResponse) Results() []interface{} { + ans := make([]interface{}, len(aar.AutomationAgents)) + for i, aa := range aar.AutomationAgents { + ans[i] = aa + } + return ans +} diff --git a/controllers/om/api/admin.go b/controllers/om/api/admin.go new file mode 100644 index 000000000..201e4de39 --- /dev/null +++ b/controllers/om/api/admin.go @@ -0,0 +1,407 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/apierror" + "github.com/10gen/ops-manager-kubernetes/pkg/util/versionutil" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/backup" +) + +type Key struct { + Description string `json:"desc"` + ID string `json:"id"` + Links []map[string]string `json:"links"` + PrivateKey string `json:"privateKey"` + PublicKey string `json:"publicKey"` + Roles []map[string]string `json:"roles"` +} + +type GlobalApiKeyRequest struct { + Description string `json:"desc"` + Roles []string `json:"roles"` +} + +type KeyResponse struct { + ApiKeys []Key `json:"results"` +} + +type Whitelist struct { + CidrBlock string `json:"cidrBlock"` + Description string `json:"description"` +} + +type S3OplogStoreAdmin interface { + // ReadS3OplogStoreConfigs returns a list of all Oplog S3Configs + ReadS3OplogStoreConfigs() ([]backup.S3Config, error) + + // UpdateS3OplogConfig updates the given Oplog S3Config + UpdateS3OplogConfig(s3Config backup.S3Config) error + + // CreateS3OplogStoreConfig creates the given Oplog S3Config + CreateS3OplogStoreConfig(s3Config backup.S3Config) error + + // DeleteS3OplogStoreConfig removes an Oplog S3config by id + DeleteS3OplogStoreConfig(id string) error +} + +type S3StoreBlockStoreAdmin interface { + // CreateS3Config creates the given S3Config + CreateS3Config(s3Config backup.S3Config) error + + // UpdateS3Config updates the given S3Config + UpdateS3Config(s3Config backup.S3Config) error + + // ReadS3Configs returns a list of all S3Configs + ReadS3Configs() ([]backup.S3Config, error) + + // DeleteS3Config removes an s3config by id + DeleteS3Config(id string) error +} + +type BlockStoreAdmin interface { + // ReadBlockStoreConfigs returns all Block stores registered in Ops Manager + ReadBlockStoreConfigs() ([]backup.DataStoreConfig, error) + + // CreateBlockStoreConfig creates an Block store in Ops Manager + CreateBlockStoreConfig(config backup.DataStoreConfig) error + + // UpdateBlockStoreConfig updates the Block store in Ops Manager + UpdateBlockStoreConfig(config backup.DataStoreConfig) error + + // DeleteBlockStoreConfig removes the Block store by its ID + DeleteBlockStoreConfig(id string) error +} + +type OplogStoreAdmin interface { + // ReadOplogStoreConfigs returns all oplog stores registered in Ops Manager + ReadOplogStoreConfigs() ([]backup.DataStoreConfig, error) + + // CreateOplogStoreConfig creates an oplog store in Ops Manager + CreateOplogStoreConfig(config backup.DataStoreConfig) error + + // UpdateOplogStoreConfig updates the oplog store in Ops Manager + UpdateOplogStoreConfig(config backup.DataStoreConfig) error + + // DeleteOplogStoreConfig removes the oplog store by its ID + DeleteOplogStoreConfig(id string) error +} + +// OpsManagerAdmin (imported as 'api.OpsManagerAdmin') is the client to all "administrator" related operations with Ops Manager +// which do not relate to specific groups (that's why it's different from 'om.Connection'). The only state expected +// to be encapsulated is baseUrl, user and key +type OpsManagerAdmin interface { + S3OplogStoreAdmin + S3StoreBlockStoreAdmin + BlockStoreAdmin + OplogStoreAdmin + // ReadDaemonConfig returns the daemon config by hostname and head db path + ReadDaemonConfig(hostName, headDbDir string) (backup.DaemonConfig, error) + + // UpdateDaemonConfig updates the daemon config + UpdateDaemonConfig(backup.DaemonConfig) error + + // CreateDaemonConfig creates the daemon config with specified hostname and head db path + CreateDaemonConfig(hostName, headDbDir string, assignmentLabels []string) error + + // ReadFileSystemStoreConfigs reads the FileSystemSnapshot store by its ID + ReadFileSystemStoreConfigs() ([]backup.DataStoreConfig, error) + + // ReadGlobalAPIKeys reads the global API Keys in Ops Manager + ReadGlobalAPIKeys() ([]Key, error) + + // CreateGlobalAPIKey creates a new Global API Key in Ops Manager + CreateGlobalAPIKey(description string) (Key, error) + + // ReadOpsManagerVersion read the version returned in the Header + ReadOpsManagerVersion() (versionutil.OpsManagerVersion, error) +} + +// AdminProvider is a function which returns an instance of OpsManagerAdmin interface initialized with connection parameters. +// The parameters can be moved to a separate struct when they grow (e.g. tls is added) +type AdminProvider func(baseUrl, user, publicApiKey string) OpsManagerAdmin + +// DefaultOmAdmin is the default (production) implementation of OpsManagerAdmin interface +type DefaultOmAdmin struct { + BaseURL string + User string + PublicAPIKey string +} + +var _ OpsManagerAdmin = &DefaultOmAdmin{} +var _ OpsManagerAdmin = &MockedOmAdmin{} + +func NewOmAdmin(baseUrl, user, publicApiKey string) OpsManagerAdmin { + return &DefaultOmAdmin{BaseURL: baseUrl, User: user, PublicAPIKey: publicApiKey} +} + +func (a *DefaultOmAdmin) ReadDaemonConfig(hostName, headDbDir string) (backup.DaemonConfig, error) { + ans, _, apiErr := a.get("admin/backup/daemon/configs/%s/%s", hostName, url.QueryEscape(headDbDir)) + if apiErr != nil { + return backup.DaemonConfig{}, apiErr + } + daemonConfig := &backup.DaemonConfig{} + if err := json.Unmarshal(ans, daemonConfig); err != nil { + return backup.DaemonConfig{}, apierror.New(err) + } + + return *daemonConfig, nil +} + +func (a *DefaultOmAdmin) UpdateDaemonConfig(config backup.DaemonConfig) error { + _, _, err := a.put("admin/backup/daemon/configs/%s/%s", config, url.QueryEscape(config.Machine.MachineHostName), url.QueryEscape(config.Machine.HeadRootDirectory)) + if err != nil { + return err + } + return nil +} + +func (a *DefaultOmAdmin) CreateDaemonConfig(hostName, headDbDir string, assignmentLabels []string) error { + config := backup.NewDaemonConfig(hostName, headDbDir, assignmentLabels) + // dev note, for creation we don't specify the second path parameter (head db) - it's used only during update + _, _, err := a.put("admin/backup/daemon/configs/%s", config, hostName) + if err != nil { + return err + } + return nil +} + +// ReadOplogStoreConfigs returns all oplog stores registered in Ops Manager +// Some assumption: while the API returns the paginated source we don't handle it to make api simpler (quite unprobable +// to have 500+ configs) +func (a *DefaultOmAdmin) ReadOplogStoreConfigs() ([]backup.DataStoreConfig, error) { + res, _, err := a.get("admin/backup/oplog/mongoConfigs/") + if err != nil { + return nil, err + } + + dataStoreConfigResponse := &backup.DataStoreConfigResponse{} + if err = json.Unmarshal(res, dataStoreConfigResponse); err != nil { + return nil, apierror.New(err) + } + + return dataStoreConfigResponse.DataStoreConfigs, nil +} + +// CreateOplogStoreConfig creates an oplog store in Ops Manager +func (a *DefaultOmAdmin) CreateOplogStoreConfig(config backup.DataStoreConfig) error { + _, _, err := a.post("admin/backup/oplog/mongoConfigs/", config) + return err +} + +// UpdateOplogStoreConfig updates an oplog store in Ops Manager +func (a *DefaultOmAdmin) UpdateOplogStoreConfig(config backup.DataStoreConfig) error { + _, _, err := a.put("admin/backup/oplog/mongoConfigs/%s", config, config.Id) + return err +} + +// DeleteOplogStoreConfig removes the oplog store by its ID +func (a *DefaultOmAdmin) DeleteOplogStoreConfig(id string) error { + return a.delete("admin/backup/oplog/mongoConfigs/%s", id) +} + +func (a *DefaultOmAdmin) ReadS3OplogStoreConfigs() ([]backup.S3Config, error) { + res, _, err := a.get("admin/backup/oplog/s3Configs") + if err != nil { + return nil, err + } + + s3Configs := &backup.S3ConfigResponse{} + if err = json.Unmarshal(res, s3Configs); err != nil { + return nil, apierror.New(err) + } + + return s3Configs.S3Configs, nil +} + +func (a *DefaultOmAdmin) UpdateS3OplogConfig(s3Config backup.S3Config) error { + _, _, err := a.put("admin/backup/oplog/s3Configs/%s", s3Config, s3Config.Id) + return err +} + +func (a *DefaultOmAdmin) CreateS3OplogStoreConfig(s3Config backup.S3Config) error { + _, _, err := a.post("admin/backup/oplog/s3Configs", s3Config) + return err +} + +func (a *DefaultOmAdmin) DeleteS3OplogStoreConfig(id string) error { + return a.delete("admin/backup/oplog/s3Configs/%s", id) +} + +// ReadBlockStoreConfigs returns all Block stores registered in Ops Manager +// Some assumption: while the API returns the paginated source we don't handle it to make api simpler (quite unprobable +// to have 500+ configs) +func (a *DefaultOmAdmin) ReadBlockStoreConfigs() ([]backup.DataStoreConfig, error) { + res, _, err := a.get("admin/backup/snapshot/mongoConfigs/") + if err != nil { + return nil, err + } + + dataStoreConfigResponse := &backup.DataStoreConfigResponse{} + if err = json.Unmarshal(res, dataStoreConfigResponse); err != nil { + return nil, apierror.New(err) + } + + return dataStoreConfigResponse.DataStoreConfigs, nil +} + +// CreateBlockStoreConfig creates an Block store in Ops Manager +func (a *DefaultOmAdmin) CreateBlockStoreConfig(config backup.DataStoreConfig) error { + _, _, err := a.post("admin/backup/snapshot/mongoConfigs/", config) + return err +} + +// UpdateBlockStoreConfig updates an Block store in Ops Manager +func (a *DefaultOmAdmin) UpdateBlockStoreConfig(config backup.DataStoreConfig) error { + _, _, err := a.put("admin/backup/snapshot/mongoConfigs/%s", config, config.Id) + return err +} + +// DeleteBlockStoreConfig removes the Block store by its ID +func (a *DefaultOmAdmin) DeleteBlockStoreConfig(id string) error { + return a.delete("admin/backup/snapshot/mongoConfigs/%s", id) +} + +// S3 related methods +func (a *DefaultOmAdmin) CreateS3Config(s3Config backup.S3Config) error { + _, _, err := a.post("admin/backup/snapshot/s3Configs", s3Config) + return err +} + +func (a *DefaultOmAdmin) UpdateS3Config(s3Config backup.S3Config) error { + _, _, err := a.put("admin/backup/snapshot/s3Configs/%s", s3Config, s3Config.Id) + return err +} + +func (a *DefaultOmAdmin) ReadS3Configs() ([]backup.S3Config, error) { + res, _, err := a.get("admin/backup/snapshot/s3Configs") + if err != nil { + return nil, apierror.New(err) + } + s3ConfigResponse := &backup.S3ConfigResponse{} + if err = json.Unmarshal(res, s3ConfigResponse); err != nil { + return nil, apierror.New(err) + } + + return s3ConfigResponse.S3Configs, nil +} + +func (a *DefaultOmAdmin) DeleteS3Config(id string) error { + return a.delete("admin/backup/snapshot/s3Configs/%s", id) +} + +func (a *DefaultOmAdmin) ReadFileSystemStoreConfigs() ([]backup.DataStoreConfig, error) { + res, _, err := a.get("admin/backup/snapshot/fileSystemConfigs/") + if err != nil { + return nil, err + } + + dataStoreConfigResponse := &backup.DataStoreConfigResponse{} + if err = json.Unmarshal(res, dataStoreConfigResponse); err != nil { + return nil, apierror.New(err) + } + + return dataStoreConfigResponse.DataStoreConfigs, nil +} + +// ReadGlobalAPIKeys reads the global API Keys in Ops Manager +func (a *DefaultOmAdmin) ReadGlobalAPIKeys() ([]Key, error) { + res, _, err := a.get("admin/apiKeys") + if err != nil { + return nil, err + } + + apiKeyResponse := &KeyResponse{} + if err = json.Unmarshal(res, apiKeyResponse); err != nil { + return nil, apierror.New(err) + } + + return apiKeyResponse.ApiKeys, nil +} + +// addWhitelistEntryIfItDoesntExist adds a whitelist through OM API. If it already exists, in just retun +func (a *DefaultOmAdmin) addWhitelistEntryIfItDoesntExist(cidrBlock string, description string) error { + _, _, err := a.post("admin/whitelist", Whitelist{ + CidrBlock: cidrBlock, + Description: description, + }) + if apierror.NewNonNil(err).ErrorCode == apierror.DuplicateWhitelistEntry { + return err + } + return nil +} + +// CreateGlobalAPIKey creates a new Global API Key in Ops Manager. +func (a *DefaultOmAdmin) CreateGlobalAPIKey(description string) (Key, error) { + if err := a.addWhitelistEntryIfItDoesntExist("0.0.0.0/1", description); err != nil { + return Key{}, err + } + if err := a.addWhitelistEntryIfItDoesntExist("128.0.0.0/1", description); err != nil { + return Key{}, err + } + + newKeyBytes, _, err := a.post("admin/apiKeys", GlobalApiKeyRequest{ + Description: description, + Roles: []string{"GLOBAL_OWNER"}, + }) + if err != nil { + return Key{}, err + } + + apiKey := &Key{} + if err := json.Unmarshal(newKeyBytes, apiKey); err != nil { + return Key{}, err + } + return *apiKey, nil + +} + +// ReadOpsManagerVersion read the version returned in the Header. +func (a *DefaultOmAdmin) ReadOpsManagerVersion() (versionutil.OpsManagerVersion, error) { + _, header, err := a.get("") + if err != nil { + return versionutil.OpsManagerVersion{}, err + } + return versionutil.OpsManagerVersion{ + VersionString: versionutil.GetVersionFromOpsManagerApiHeader(header.Get("X-MongoDB-Service-Version")), + }, nil +} + +//********************************** Private methods ******************************************************************* + +func (a *DefaultOmAdmin) get(path string, params ...interface{}) ([]byte, http.Header, error) { + return a.httpVerb("GET", path, nil, params...) +} + +func (a *DefaultOmAdmin) put(path string, v interface{}, params ...interface{}) ([]byte, http.Header, error) { + return a.httpVerb("PUT", path, v, params...) +} + +func (a *DefaultOmAdmin) post(path string, v interface{}, params ...interface{}) ([]byte, http.Header, error) { + return a.httpVerb("POST", path, v, params...) +} + +//func (a *DefaultOmAdmin) patch(path string, v interface{}, params ...interface{}) ([]byte, error) { +// return a.httpVerb("PATCH", path, v, params...) +//} + +func (a *DefaultOmAdmin) delete(path string, params ...interface{}) error { + _, _, err := a.httpVerb("DELETE", path, nil, params...) + return err +} + +func (a *DefaultOmAdmin) httpVerb(method, path string, v interface{}, params ...interface{}) ([]byte, http.Header, error) { + client, err := NewHTTPClient(OptionDigestAuth(a.User, a.PublicAPIKey), OptionSkipVerify) + if err != nil { + return nil, nil, apierror.New(err) + } + + path = fmt.Sprintf("/api/public/v1.0/%s", path) + path = fmt.Sprintf(path, params...) + + return client.Request(method, a.BaseURL, path, v) +} diff --git a/controllers/om/api/digest.go b/controllers/om/api/digest.go new file mode 100644 index 000000000..638f177e2 --- /dev/null +++ b/controllers/om/api/digest.go @@ -0,0 +1,71 @@ +package api + +import ( + "crypto/rand" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/apierror" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" +) + +// parseAPIError +func parseAPIError(statusCode int, method, url string, body []byte) *apierror.Error { + // If no body - returning the error object with only HTTP status + if body == nil { + return &apierror.Error{ + Status: &statusCode, + Detail: fmt.Sprintf("%s %v failed with status %d with no response body", method, url, statusCode), + } + } + // If response body exists - trying to parse it + errorObject := &apierror.Error{} + if err := json.Unmarshal(body, errorObject); err != nil { + // If parsing has failed - returning just the general error with status code + return &apierror.Error{ + Status: &statusCode, + Detail: fmt.Sprintf("%s %v failed with status %d with response body: %s", method, url, statusCode, string(body)), + } + } + + return errorObject +} + +func digestParts(resp *http.Response) map[string]string { + result := map[string]string{} + if len(resp.Header["Www-Authenticate"]) > 0 { + wantedHeaders := []string{"nonce", "realm", "qop"} + responseHeaders := strings.Split(resp.Header["Www-Authenticate"][0], ",") + for _, r := range responseHeaders { + for _, w := range wantedHeaders { + if strings.Contains(r, w) { + result[w] = strings.Split(r, `"`)[1] + break + } + } + } + } + return result +} + +func getCnonce() string { + b := make([]byte, 8) + io.ReadFull(rand.Reader, b) + return fmt.Sprintf("%x", b)[:16] +} + +func getDigestAuthorization(digestParts map[string]string, method string, url string, user string, token string) string { + d := digestParts + ha1 := util.MD5Hex(user + ":" + d["realm"] + ":" + token) + ha2 := util.MD5Hex(method + ":" + url) + nonceCount := 00000001 + cnonce := getCnonce() + response := util.MD5Hex(fmt.Sprintf("%s:%s:%v:%s:%s:%s", ha1, d["nonce"], nonceCount, cnonce, d["qop"], ha2)) + authorization := fmt.Sprintf(`Digest username="%s", realm="%s", nonce="%s", uri="%s", cnonce="%s", nc=%v, qop=%s, response="%s", algorithm="MD5"`, + user, d["realm"], d["nonce"], url, cnonce, nonceCount, d["qop"], response) + return authorization +} diff --git a/controllers/om/api/http.go b/controllers/om/api/http.go new file mode 100644 index 000000000..579ef7a69 --- /dev/null +++ b/controllers/om/api/http.go @@ -0,0 +1,299 @@ +package api + +import ( + "bytes" + "crypto/tls" + "crypto/x509" + "encoding/json" + "io" + "io/ioutil" + "net/http" + "net/http/httputil" + "os" + "strconv" + "strings" + "time" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/prometheus/client_golang/prometheus" + "golang.org/x/xerrors" + "sigs.k8s.io/controller-runtime/pkg/metrics" + + "go.uber.org/zap" + + "github.com/hashicorp/go-retryablehttp" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/apierror" +) + +const ( + OMClientSubsystem = "om_client" + ResultKey = "requests_total" +) + +var ( + omClient = prometheus.NewCounterVec(prometheus.CounterOpts{ + Subsystem: OMClientSubsystem, + Name: ResultKey, + Help: "Number of HTTP requests, partitioned by status code, method, and path.", + }, []string{"code", "method", "path"}) +) + +func init() { + // Register custom metrics with the global prometheus registry + if os.Getenv(util.OmOperatorEnv) != util.OperatorEnvironmentProd { + zap.S().Debugf("collecting operator specific debug metrics") + registerClientMetrics() + } +} + +// registerClientMetrics sets up the operator om client metrics. +func registerClientMetrics() { + // register the metrics with our registry + metrics.Registry.MustRegister(omClient) +} + +const ( + defaultRetryWaitMin = 1 * time.Second + defaultRetryWaitMax = 10 * time.Second + defaultRetryMax = 3 +) + +type Client struct { + *retryablehttp.Client + + // Digest username and password + username string + password string + + // Enable debugging information on this client + debug bool +} + +// NewHTTPClient is a functional options constructor, based on this blog post: +// https://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis +// The default clients specifies some important timeouts (some of them are synced with AA one): +// 10 seconds for connection (TLS/non TLS) +// 10 minutes for requests (time to get the first response headers) +func NewHTTPClient(options ...func(*Client) error) (*Client, error) { + client := &Client{ + Client: newDefaultHTTPClient(), + } + + return applyOptions(client, options...) +} + +func newDefaultHTTPClient() *retryablehttp.Client { + return &retryablehttp.Client{ + HTTPClient: &http.Client{Transport: http.DefaultTransport}, + RetryWaitMin: defaultRetryWaitMin, + RetryWaitMax: defaultRetryWaitMax, + RetryMax: defaultRetryMax, + // Will retry on all errors + CheckRetry: retryablehttp.DefaultRetryPolicy, + // Exponential backoff based on the attempt number and limited by the provided minimum and maximum durations. + // We don't need Jitter here as we're the only client to the OM, so there's no risk + // of overwhelming it in a peek. + Backoff: retryablehttp.DefaultBackoff, + } +} + +func applyOptions(client *Client, options ...func(*Client) error) (*Client, error) { + for _, op := range options { + err := op(client) + if err != nil { + return nil, err + } + } + + return client, nil +} + +// NewHTTPOptions returns a list of options that can be used to construct an +// *http.Client using `NewHTTPClient`. +func NewHTTPOptions() []func(*Client) error { + return make([]func(*Client) error, 0) +} + +// OptionsDigestAuth enables Digest authentication. +func OptionDigestAuth(username, password string) func(client *Client) error { + return func(client *Client) error { + client.username = username + client.password = password + + return nil + } +} + +// OptionDebug enables debug on the http client. +func OptionDebug(client *Client) error { + client.debug = true + + return nil +} + +// OptionSkipVerify will set the Insecure Skip which means that TLS certs will not be +// verified for validity. +func OptionSkipVerify(client *Client) error { + TLSClientConfig := &tls.Config{InsecureSkipVerify: true} + + transport := client.HTTPClient.Transport.(*http.Transport) + transport.TLSClientConfig = TLSClientConfig + client.HTTPClient.Transport = transport + + return nil +} + +// OptionCAValidate will use the CA certificate, passed as a string, to validate the +// certificates presented by Ops Manager. +func OptionCAValidate(ca string) func(client *Client) error { + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM([]byte(ca)) + TLSClientConfig := &tls.Config{ + InsecureSkipVerify: false, + RootCAs: caCertPool, + } + + return func(client *Client) error { + transport := client.HTTPClient.Transport.(*http.Transport) + transport.TLSClientConfig = TLSClientConfig + client.HTTPClient.Transport = transport + + return nil + } +} + +// Request executes an HTTP request, given a series of parameters, over this *Client object. +// It handles Digest when needed and json marshaling of the `v` struct. +func (client *Client) Request(method, hostname, path string, v interface{}) ([]byte, http.Header, error) { + url := hostname + path + + buffer, err := serializeToBuffer(v) + if err != nil { + return nil, nil, apierror.New(err) + } + + req, _ := createHTTPRequest(method, url, buffer) + + if client.username != "" && client.password != "" { + // Only add Digest auth when needed. + err = client.authorizeRequest(method, hostname, path, req) + if err != nil { + return nil, nil, err + } + } + + client.RequestLogHook = func(logger retryablehttp.Logger, request *http.Request, i int) { + if client.debug { + dumpRequest, _ := httputil.DumpRequest(request, true) + zap.S().Debugf("Ops Manager request (%d): %s %s\n \n %s", i, method, path, dumpRequest) + } else { + zap.S().Debugf("Ops Manager request: %s %s", method, url) + } + } + + client.ResponseLogHook = func(logger retryablehttp.Logger, response *http.Response) { + if client.debug { + if !strings.Contains(path, "automationConfig") { + dumpRequest, _ := httputil.DumpResponse(response, true) + zap.S().Debugf("Ops Manager response: %s %s\n \n %s", method, path, dumpRequest) + } else { + zap.S().Debugf("Ops Manager response: %s %s\n", method, path) + } + } + } + + resp, err := client.Do(req) + if err != nil { + return nil, nil, apierror.New(xerrors.Errorf("error sending %s request to %s: %w", method, url, err)) + } + + omClient.WithLabelValues(strconv.Itoa(resp.StatusCode), method, path).Inc() + + // need to clear hooks, because otherwise they will be persisted for the subsequent calls + // resulting in logging authorizeRequest + client.RequestLogHook = nil + client.ResponseLogHook = nil + + // It is required for the body to be read completely for the connection to be reused. + // https://stackoverflow.com/a/17953506/75928 + body, err := ioutil.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, nil, apierror.New(xerrors.Errorf("Error reading response body from %s to %v status=%v", method, url, resp.StatusCode)) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + apiError := parseAPIError(resp.StatusCode, method, url, body) + return nil, nil, apiError + } + + return body, resp.Header, nil +} + +// authorizeRequest executes one request that's meant to be challenged by the +// server in order to build the next one. The `request` parameter is aggregated +// with the required `Authorization` header. +func (client *Client) authorizeRequest(method, hostname, path string, request *retryablehttp.Request) error { + url := hostname + path + + digestRequest, err := retryablehttp.NewRequest(method, url, nil) + if err != nil { + return err + } + resp, err := client.Do(digestRequest) + if err != nil { + return err + } + defer resp.Body.Close() + + _, err = ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusUnauthorized { + return apierror.New( + xerrors.Errorf( + "Received status code '%v' (%v) but expected the '%d', requested url: %v", + resp.StatusCode, + resp.Status, + http.StatusUnauthorized, + digestRequest.URL, + ), + ) + + } + digestParts := digestParts(resp) + digestAuth := getDigestAuthorization(digestParts, method, path, client.username, client.password) + + request.Header.Set("Authorization", digestAuth) + + return nil +} + +// createHTTPRequest +func createHTTPRequest(method string, url string, reader io.Reader) (*retryablehttp.Request, error) { + req, err := retryablehttp.NewRequest(method, url, reader) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", "application/json; charset=UTF-8") + req.Header.Add("Provider", "KUBERNETES") + + return req, nil +} + +// serializeToBuffer takes any object and tries to serialize it to the buffer +func serializeToBuffer(v interface{}) (io.Reader, error) { + var buffer io.Reader + if v != nil { + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + buffer = bytes.NewBuffer(b) + } + return buffer, nil +} diff --git a/controllers/om/api/http_test.go b/controllers/om/api/http_test.go new file mode 100644 index 000000000..1d6f868f4 --- /dev/null +++ b/controllers/om/api/http_test.go @@ -0,0 +1,20 @@ +package api + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCreateHttpRequest(t *testing.T) { + httpRequest, e := createHTTPRequest("POST", "http://some.com", nil) + u, _ := url.Parse("http://some.com") + + assert.NoError(t, e) + assert.Equal(t, []string{"application/json; charset=UTF-8"}, httpRequest.Header["Content-Type"]) + assert.Equal(t, []string{"KUBERNETES"}, httpRequest.Header["Provider"]) + assert.Equal(t, "POST", httpRequest.Method) + assert.Equal(t, u, httpRequest.URL) + assert.Nil(t, httpRequest.Body) +} diff --git a/controllers/om/api/initializer.go b/controllers/om/api/initializer.go new file mode 100644 index 000000000..6dd590a4c --- /dev/null +++ b/controllers/om/api/initializer.go @@ -0,0 +1,173 @@ +package api + +import ( + "encoding/json" + "fmt" + "io/ioutil" + + "net/url" + + "github.com/10gen/ops-manager-kubernetes/pkg/util/versionutil" + "github.com/blang/semver" + "golang.org/x/xerrors" +) + +// This is a separate om functionality needed for OM controller +// The 'TryCreateUser' method doesn't use authentication so doesn't use the digest auth. +// That's why it's not added into the 'omclient.go' file + +// KubernetesNetMask seems to be the default k8s cluster mask we need to whitelist +// (see https://github.com/kubernetes/kops/issues/2564) +// TODO we can try to guess it (CLOUDP-51402) +const KubernetesNetMask = "100.96.0.0%2F16" + +// Initializer knows how to make calls to Ops Manager to create a first user +type Initializer interface { + // TryCreateUser makes the call to Ops Manager to create the first admin user. Returns the public API key or an + // error if the user already exists for example + TryCreateUser(omUrl string, omVersion string, user User) (OpsManagerKeyPair, error) +} + +// DefaultInitializer is the "production" implementation of 'Initializer'. Seems we don't need to keep any state +// as the clients won't call the 'TryCreateUser' more than once after struct instantiation +type DefaultInitializer struct{} + +// User is a json struct corresponding to mms 'ApiAppUserView' object +type User struct { + Username string `json:"username"` + Password string `json:"password"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` +} + +// UserKeys is a json struct corresponding to mms 'ApiAppUserAndApiKeyView' +// object +type UserKeys struct { + ApiKey string `json:"apiKey"` +} + +// ResultProgrammaticAPIKey struct that deserializes to the result of +// calling `unauth/users` Ops Manager endpoint. +type ResultProgrammaticAPIKey struct { + ProgrammaticAPIKey OpsManagerKeyPair `json:"programmaticApiKey"` +} + +// OpsManagerKeyPair convenient type we use to fetch credentials from different +// versions of Ops Manager API. +type OpsManagerKeyPair struct { + PrivateKey string `json:"privateKey"` + PublicKey string `json:"publicKey"` +} + +// TryCreateUser makes the call to the special OM endpoint '/unauth/users' which +// creates a GLOBAL_ADMIN user and generates public API token. +// +// If the endpoint has been called already and a user already exist, then it +// will return HTTP-409. +// +// If this endpoint is called once again with a different `username` the user +// will be created but with no `GLOBAL_ADMIN` role. +func (o *DefaultInitializer) TryCreateUser(omUrl string, omVersion string, user User) (OpsManagerKeyPair, error) { + buffer, err := serializeToBuffer(user) + if err != nil { + return OpsManagerKeyPair{}, err + } + + // As of now, there is no HTTPS context that we pass to the operator, so we'll skip + // the HTTPS verification, because this OM instance was just created by the operator itself + // and we should trust it. + client, err := NewHTTPClient(OptionSkipVerify) + if err != nil { + return OpsManagerKeyPair{}, err + } + // dev note: we are doing many similar things that 'http.go' does - though we cannot reuse that now as current + // request is not a digest one + endpoint := buildOMUnauthEndpoint(omUrl) + resp, err := client.Post(endpoint, "application/json; charset=UTF-8", buffer) + + if err != nil { + return OpsManagerKeyPair{}, xerrors.Errorf("Error sending POST request to %s: %w", omUrl, err) + } + + var body []byte + if resp.Body != nil { + defer resp.Body.Close() + body, err = ioutil.ReadAll(resp.Body) + if err != nil { + return OpsManagerKeyPair{}, xerrors.Errorf("Error reading response body from %v status=%v", omUrl, resp.StatusCode) + } + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + apiError := parseAPIError(resp.StatusCode, "post", omUrl, body) + return OpsManagerKeyPair{}, apiError + } + + return fetchOMCredentialsFromResponse(body, omVersion, user) +} + +// buildOMUnauthEndpoint returns a string pointing at the unauth/users endpoint +// needed to create the first global owner user. +func buildOMUnauthEndpoint(baseUrl string) string { + u, err := url.Parse(baseUrl) + if err != nil { + panic(fmt.Sprintf("Could not parse %s as a url", baseUrl)) + } + + q := u.Query() + q.Add("whitelist", "0.0.0.0/1") + q.Add("whitelist", "128.0.0.0/1") + q.Add("pretty", "true") + + u.Path = "/api/public/v1.0/unauth/users" + u.RawQuery = q.Encode() + + return u.String() +} + +// fetchOMCredentialsFromResponse returns a `OpsManagerKeyPair` which consist of +// a public and private parts. +// +// This function deserializes the result of calling the `unauth/users` endpoint +// and returns a generic credentials object that can work for both 5.0 and +// pre-5.0 OM versions. +// +// One important detail about the returned value is that in the old-style user +// APIs, the entry called `PublicAPIKey` corresponds to `PrivateAPIKey` in +// programmatic key, and `Username` corresponds to `PublicAPIKey`. +func fetchOMCredentialsFromResponse(body []byte, omVersion string, user User) (OpsManagerKeyPair, error) { + version, err := versionutil.StringToSemverVersion(omVersion) + if err != nil { + return OpsManagerKeyPair{}, err + } + + if semver.MustParseRange(">=5.0.0")(version) { + // Ops Manager 5.0.0+ returns a Programmatic API Key + apiKey := &ResultProgrammaticAPIKey{} + if err := json.Unmarshal(body, apiKey); err != nil { + return OpsManagerKeyPair{}, err + } + + if apiKey.ProgrammaticAPIKey.PrivateKey != "" && apiKey.ProgrammaticAPIKey.PublicKey != "" { + return apiKey.ProgrammaticAPIKey, nil + } + + return OpsManagerKeyPair{}, xerrors.Errorf("Could not fetch credentials from Ops Manager") + } + + // OpsManager up to 4.4.x return a user API key + u := &UserKeys{} + if err := json.Unmarshal(body, u); err != nil { + return OpsManagerKeyPair{}, err + } + + if u.ApiKey == "" { + return OpsManagerKeyPair{}, xerrors.Errorf("Could not find a Global API key from Ops Manager") + } + + return OpsManagerKeyPair{ + PublicKey: user.Username, + PrivateKey: u.ApiKey, + }, nil + +} diff --git a/controllers/om/api/initializer_test.go b/controllers/om/api/initializer_test.go new file mode 100644 index 000000000..6a92e6eb7 --- /dev/null +++ b/controllers/om/api/initializer_test.go @@ -0,0 +1,90 @@ +package api + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuildOMEndpoint(t *testing.T) { + var endpoint string + + endpoint = buildOMUnauthEndpoint("https://om") + assert.Equal(t, "https://om/api/public/v1.0/unauth/users?pretty=true&whitelist=0.0.0.0%2F1&whitelist=128.0.0.0%2F1", endpoint) + + endpoint = buildOMUnauthEndpoint("http://om:8080") + assert.Equal(t, "http://om:8080/api/public/v1.0/unauth/users?pretty=true&whitelist=0.0.0.0%2F1&whitelist=128.0.0.0%2F1", endpoint) +} + +func TestFetchOMCredentialsFromResponseOMPre50(t *testing.T) { + user := User{ + Username: "some-user", + Password: "whatever", + FirstName: "Some First Name", + LastName: "Some Last Name", + } + + bodyStr := `{"apiKey": "hola"}` + body := []byte(bodyStr) + + credentials, err := fetchOMCredentialsFromResponse(body, "4.2.2", user) + assert.NoError(t, err) + + assert.Equal(t, credentials, + OpsManagerKeyPair{ + PrivateKey: "hola", + PublicKey: "some-user", + }) + + bodyStr = `{}` + body = []byte(bodyStr) + + credentials, err = fetchOMCredentialsFromResponse(body, "4.2.2", user) + assert.Equal(t, err.Error(), "Could not find a Global API key from Ops Manager") + + assert.Equal(t, credentials, + OpsManagerKeyPair{ + PrivateKey: "", + PublicKey: "", + }) +} + +func TestFetchOMCredentialsFromResponseOM50(t *testing.T) { + user := User{ + Username: "some-user", + Password: "whatever", + FirstName: "Some First Name", + LastName: "Some Last Name", + } + + bodyStr := `{"programmaticApiKey": {"privateKey": "returned-private-key", "publicKey": "returned-public-key"}}` + body := []byte(bodyStr) + + expected := OpsManagerKeyPair{ + PrivateKey: "returned-private-key", + PublicKey: "returned-public-key", + } + + var credentials OpsManagerKeyPair + var err error + + credentials, err = fetchOMCredentialsFromResponse(body, "5.0.1", user) + assert.NoError(t, err) + assert.Equal(t, credentials, expected) + + credentials, err = fetchOMCredentialsFromResponse(body, "5.0.2", user) + assert.NoError(t, err) + assert.Equal(t, credentials, expected) + + credentials, err = fetchOMCredentialsFromResponse(body, "6.0.2", user) + assert.NoError(t, err) + assert.Equal(t, credentials, expected) + + expected = OpsManagerKeyPair{ + PrivateKey: "", + PublicKey: "", + } + credentials, err = fetchOMCredentialsFromResponse(body, "4.4.99", user) + assert.Equal(t, err.Error(), "Could not find a Global API key from Ops Manager") + assert.Equal(t, credentials, expected) +} diff --git a/controllers/om/api/mockedomadmin.go b/controllers/om/api/mockedomadmin.go new file mode 100644 index 000000000..ea4263164 --- /dev/null +++ b/controllers/om/api/mockedomadmin.go @@ -0,0 +1,253 @@ +package api + +import ( + "errors" + "fmt" + "sort" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/apierror" + "github.com/10gen/ops-manager-kubernetes/pkg/util/versionutil" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/backup" +) + +// ******************************************************************************************************************** +// This is a mock for om admin. It's created as a normal (not a test) go file to allow different packages use it for +// testing. +// Surely don't use it in production :) +// ******************************************************************************************************************** + +// Global variable for current OM admin object that was created by MongoDbOpsManager - just for tests +// It's important to clear the state on time - the lifespan of om admin is supposed to be bound to a lifespan of a +// OM controller instance - once a new OM controller is created the mocked admin state must be cleared +var CurrMockedAdmin *MockedOmAdmin + +type MockedOmAdmin struct { + // These variables are not used internally but are used to check the correctness of parameters passed by the controller + BaseURL string + PublicKey string + PrivateKey string + + daemonConfigs []backup.DaemonConfig + s3Configs map[string]backup.S3Config + s3OpLogConfigs map[string]backup.S3Config + oplogConfigs map[string]backup.DataStoreConfig + blockStoreConfigs map[string]backup.DataStoreConfig + fileSystemStoreConfigs map[string]backup.DataStoreConfig + apiKeys []Key +} + +func (a *MockedOmAdmin) UpdateDaemonConfig(config backup.DaemonConfig) error { + for _, dc := range a.daemonConfigs { + if dc.Machine.MachineHostName == config.Machine.HeadRootDirectory { + dc.AssignmentEnabled = config.AssignmentEnabled + return nil + } + } + return apierror.NewErrorWithCode(apierror.BackupDaemonConfigNotFound) +} + +// NewMockedAdminProvider is the function creating the admin object. The function returns the existing mocked admin instance +// if it exists - this allows to survive through multiple reconciliations and to keep OM state over them +func NewMockedAdminProvider(baseUrl, publicApiKey, privateApiKey string) OpsManagerAdmin { + CurrMockedAdmin = &MockedOmAdmin{} + CurrMockedAdmin.BaseURL = baseUrl + CurrMockedAdmin.PublicKey = publicApiKey + CurrMockedAdmin.PrivateKey = privateApiKey + + CurrMockedAdmin.daemonConfigs = make([]backup.DaemonConfig, 0) + CurrMockedAdmin.s3Configs = make(map[string]backup.S3Config) + CurrMockedAdmin.s3OpLogConfigs = make(map[string]backup.S3Config) + CurrMockedAdmin.oplogConfigs = make(map[string]backup.DataStoreConfig) + CurrMockedAdmin.blockStoreConfigs = make(map[string]backup.DataStoreConfig) + CurrMockedAdmin.apiKeys = []Key{{ + PrivateKey: privateApiKey, + PublicKey: publicApiKey, + }} + + return CurrMockedAdmin +} + +func (a *MockedOmAdmin) Reset() { + NewMockedAdminProvider(a.BaseURL, a.PublicKey, a.PrivateKey) +} + +// NewMockedAdmin creates an empty mocked om admin. This must be called by tests when the Om controller is created to +// make sure the state is cleaned +func NewMockedAdmin() *MockedOmAdmin { + CurrMockedAdmin = &MockedOmAdmin{} + return CurrMockedAdmin +} + +func (a *MockedOmAdmin) ReadDaemonConfigs() ([]backup.DaemonConfig, error) { + return a.daemonConfigs, nil +} + +func (a *MockedOmAdmin) ReadDaemonConfig(hostName, headDbDir string) (backup.DaemonConfig, error) { + for _, v := range a.daemonConfigs { + if v.Machine.HeadRootDirectory == headDbDir && v.Machine.MachineHostName == hostName { + return v, nil + } + } + return backup.DaemonConfig{}, apierror.NewErrorWithCode(apierror.BackupDaemonConfigNotFound) +} + +func (a *MockedOmAdmin) CreateDaemonConfig(hostName, headDbDir string, assignmentLabels []string) error { + config := backup.NewDaemonConfig(hostName, headDbDir, assignmentLabels) + + for _, dConf := range a.daemonConfigs { + // Ops Manager should never be performing Update Operations, only Creations + if dConf.Machine.HeadRootDirectory == headDbDir && dConf.Machine.MachineHostName == hostName { + panic(fmt.Sprintf("Config %s, %s already exists", hostName, headDbDir)) + } + } + + a.daemonConfigs = append(a.daemonConfigs, config) + return nil +} + +func (a *MockedOmAdmin) ReadS3Configs() ([]backup.S3Config, error) { + allConfigs := make([]backup.S3Config, 0) + for _, v := range a.s3Configs { + allConfigs = append(allConfigs, v) + } + + sort.SliceStable(allConfigs, func(i, j int) bool { + return allConfigs[i].Id < allConfigs[j].Id + }) + + return allConfigs, nil +} + +func (a *MockedOmAdmin) DeleteS3Config(id string) error { + if _, ok := a.s3Configs[id]; !ok { + return errors.New("Failed to remove as the s3 config doesn't exist!") + } + delete(a.s3Configs, id) + return nil +} + +func (a *MockedOmAdmin) CreateS3Config(s3Config backup.S3Config) error { + a.s3Configs[s3Config.Id] = s3Config + return nil +} + +func (a *MockedOmAdmin) UpdateS3Config(s3Config backup.S3Config) error { + return a.CreateS3Config(s3Config) +} + +func (a *MockedOmAdmin) ReadOplogStoreConfigs() ([]backup.DataStoreConfig, error) { + allConfigs := make([]backup.DataStoreConfig, 0) + for _, v := range a.oplogConfigs { + allConfigs = append(allConfigs, v) + } + + sort.SliceStable(allConfigs, func(i, j int) bool { + return allConfigs[i].Id < allConfigs[j].Id + }) + + return allConfigs, nil +} + +func (a *MockedOmAdmin) CreateOplogStoreConfig(config backup.DataStoreConfig) error { + // Note, that backup API doesn't throw an error if the config already exists - it just updates it + a.oplogConfigs[config.Id] = config + return nil +} + +func (a *MockedOmAdmin) UpdateOplogStoreConfig(config backup.DataStoreConfig) error { + a.oplogConfigs[config.Id] = config + // OM backup service doesn't throw any errors if the config is not there + return nil +} + +func (a *MockedOmAdmin) DeleteOplogStoreConfig(id string) error { + if _, ok := a.oplogConfigs[id]; !ok { + return errors.New("Failed to remove as the oplog doesn't exist!") + } + delete(a.oplogConfigs, id) + return nil +} + +func (a *MockedOmAdmin) ReadS3OplogStoreConfigs() ([]backup.S3Config, error) { + allConfigs := make([]backup.S3Config, 0) + for _, v := range a.s3OpLogConfigs { + allConfigs = append(allConfigs, v) + } + + return allConfigs, nil +} + +func (a *MockedOmAdmin) UpdateS3OplogConfig(s3Config backup.S3Config) error { + a.s3OpLogConfigs[s3Config.Id] = s3Config + return nil +} + +func (a *MockedOmAdmin) CreateS3OplogStoreConfig(s3Config backup.S3Config) error { + return a.UpdateS3OplogConfig(s3Config) +} + +func (a *MockedOmAdmin) DeleteS3OplogStoreConfig(id string) error { + if _, ok := a.s3OpLogConfigs[id]; !ok { + return errors.New("Failed to remove as the s3 oplog doesn't exist!") + } + delete(a.s3OpLogConfigs, id) + return nil +} + +func (a *MockedOmAdmin) ReadBlockStoreConfigs() ([]backup.DataStoreConfig, error) { + allConfigs := make([]backup.DataStoreConfig, 0) + for _, v := range a.blockStoreConfigs { + allConfigs = append(allConfigs, v) + } + + sort.SliceStable(allConfigs, func(i, j int) bool { + return allConfigs[i].Id < allConfigs[j].Id + }) + + return allConfigs, nil +} + +func (a *MockedOmAdmin) CreateBlockStoreConfig(config backup.DataStoreConfig) error { + a.blockStoreConfigs[config.Id] = config + return nil +} + +func (a *MockedOmAdmin) UpdateBlockStoreConfig(config backup.DataStoreConfig) error { + a.blockStoreConfigs[config.Id] = config + // OM backup service doesn't throw any errors if the config is not there + return nil +} + +func (a *MockedOmAdmin) DeleteBlockStoreConfig(id string) error { + if _, ok := a.blockStoreConfigs[id]; !ok { + return errors.New("Failed to remove as the block store doesn't exist!") + } + delete(a.blockStoreConfigs, id) + return nil +} + +func (a *MockedOmAdmin) ReadFileSystemStoreConfigs() ([]backup.DataStoreConfig, error) { + allConfigs := make([]backup.DataStoreConfig, len(a.blockStoreConfigs)) + for _, v := range a.fileSystemStoreConfigs { + allConfigs = append(allConfigs, v) + } + return allConfigs, nil +} + +func (a *MockedOmAdmin) ReadGlobalAPIKeys() ([]Key, error) { + return a.apiKeys, nil +} + +func (a *MockedOmAdmin) CreateGlobalAPIKey(description string) (Key, error) { + newKey := Key{ + Description: description, + Roles: []map[string]string{{"role_name": "GLOBAL_ONWER"}}, + } + a.apiKeys = append(a.apiKeys, newKey) + return newKey, nil +} + +func (a *MockedOmAdmin) ReadOpsManagerVersion() (versionutil.OpsManagerVersion, error) { + return versionutil.OpsManagerVersion{}, nil +} diff --git a/controllers/om/apierror/api_error.go b/controllers/om/apierror/api_error.go new file mode 100644 index 000000000..848d75f37 --- /dev/null +++ b/controllers/om/apierror/api_error.go @@ -0,0 +1,88 @@ +package apierror + +import ( + "fmt" +) + +const ( + // Error codes that Ops Manager may return that we are concerned about + InvalidAttribute = "INVALID_ATTRIBUTE" + OrganizationNotFound = "ORG_NAME_NOT_FOUND" + ProjectNotFound = "GROUP_NAME_NOT_FOUND" + BackupDaemonConfigNotFound = "DAEMON_MACHINE_CONFIG_NOT_FOUND" + UserAlreadyExists = "USER_ALREADY_EXISTS" + S3ConfigNotFound = "S3_SNAPSHOT_CONFIG_NOT_FOUND" + DuplicateWhitelistEntry = "DUPLICATE_GLOBAL_WHITELIST_ENTRY" +) + +// Error is the error extension that contains the details of OM error if OM returned the error. This allows the +// code using Connection methods to do more fine-grained exception handling depending on exact error that happened. +// The class has to encapsulate the usual error (non-OM one) as well as the error may happen at any stage before/after +// OM request (failing to (de)serialize json object for example) so in this case all fields except for 'Detail' will be +// empty +type Error struct { + Status *int `json:"error"` + Reason string `json:"reason"` + Detail string `json:"detail"` + ErrorCode string `json:"errorCode"` +} + +// New returns either the error itself if it's of type 'api.Error' or an 'Error' created from a normal string +func New(err error) error { + if err == nil { + return nil + } + switch v := err.(type) { + case *Error: + return v + default: + return &Error{Detail: err.Error()} + } +} + +// NewNonNil returns empty 'Error' if the incoming parameter is nil. This allows to perform the checks for +// error code without risks to get nil pointer +// Unfortunately we have to do this as we cannot return *Error directly in our method signatures +// (https://golang.org/doc/faq#nil_error) +func NewNonNil(err error) *Error { + if err == nil { + return &Error{} + } + return New(err).(*Error) +} + +// NewErrorWithCode returns the Error initialized with the code passed. This is convenient for testing. +func NewErrorWithCode(code string) *Error { + return &Error{ErrorCode: code} +} + +// Error +func (e *Error) Error() string { + if e.Status != nil || e.ErrorCode != "" { + msg := "" + if e.Status != nil { + msg += fmt.Sprintf("Status: %d", *e.Status) + } + if e.Reason != "" { + msg += fmt.Sprintf(" (%s)", e.Reason) + } + if e.ErrorCode != "" { + msg += fmt.Sprintf(", ErrorCode: %s", e.ErrorCode) + } + if e.Detail != "" { + msg += fmt.Sprintf(", Detail: %s", e.Detail) + } + return msg + } + return e.Detail +} + +// ErrorCodeIn +func (e *Error) ErrorCodeIn(errorCodes ...string) bool { + for _, c := range errorCodes { + if e.ErrorCode == c { + return true + } + } + return false +} diff --git a/controllers/om/appdb_process.go b/controllers/om/appdb_process.go new file mode 100644 index 000000000..10ed6397a --- /dev/null +++ b/controllers/om/appdb_process.go @@ -0,0 +1,45 @@ +package om + +import ( + "fmt" + "path" + + omv1 "github.com/10gen/ops-manager-kubernetes/api/v1/om" + "github.com/10gen/ops-manager-kubernetes/pkg/tls" + "github.com/10gen/ops-manager-kubernetes/pkg/util" +) + +// TODO this file is intentionally created separately and duplicates the functions without "AppDB" suffix +// This is intended for the Ops Manager alpha, later the proper abstractions refactoring should be done +// (when all MongoDB fields are supported for AppDB): +// - both MongoDBSpec and AppDB objects implement the same interface to get access/mutate the same fields +// - both MongoDBSpec and AppDB include some common struct with all fields and all methods implementation. This is the +// same trick as including 'metav1.ObjectMeta' into 'MongoDB' which allows to implement 'meta.v1.Object' automatically +// - after this all the Operator/OpsManager methods dealing with 'mongodb.MongoDB' can be switched to this new interface + +// NewMongodProcess +func NewMongodProcessAppDB(name, hostName string, appdb *omv1.AppDBSpec) Process { + p := createProcess( + WithName(name), + WithHostname(hostName), + WithProcessType(ProcessTypeMongod), + WithAdditionalMongodConfig(*appdb.GetAdditionalMongodConfig()), + WithResourceSpec(appdb), + ) + + if appdb.GetSecurity().IsTLSEnabled() { + certFile := fmt.Sprintf("%s/certs/%s-pem", util.SecretVolumeMountPath, name) + + // Process for AppDB use the mounted cert in-place and are not required for the certs to be + // linked into a given location. + p.ConfigureTLS(tls.Require, certFile) + } + + // default values for configurable values + p.SetDbPath("/data") + // CLOUDP-33467: we put mongod logs to the same directory as AA/Monitoring/Backup ones to provide single mount point + // for all types of logs + p.SetLogPath(path.Join(util.PvcMountPathLogs, "mongodb.log")) + + return p +} diff --git a/controllers/om/appdb_process_test.go b/controllers/om/appdb_process_test.go new file mode 100644 index 000000000..34f247e78 --- /dev/null +++ b/controllers/om/appdb_process_test.go @@ -0,0 +1,56 @@ +package om + +import ( + "testing" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + omv1 "github.com/10gen/ops-manager-kubernetes/api/v1/om" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/stretchr/testify/assert" +) + +func defaultMongoDBAppDBVersioned(version string) *omv1.AppDBSpec { + appdb := *omv1.DefaultAppDbBuilder().Build() + appdb.Version = version + + return &appdb +} + +func TestCreateMongodProcessAppDB(t *testing.T) { + process := NewMongodProcessAppDB("trinity", "trinity-0.trinity-svc.svc.cluster.local", defaultMongoDBAppDBVersioned("4.0.5")) + + assert.Equal(t, "trinity", process.Name()) + assert.Equal(t, "trinity-0.trinity-svc.svc.cluster.local", process.HostName()) + assert.Equal(t, "4.0.5", process.Version()) + assert.Equal(t, "4.0", process.FeatureCompatibilityVersion()) + assert.Equal(t, "/data", process.DbPath()) + assert.Equal(t, "/var/log/mongodb-mms-automation/mongodb.log", process.LogPath()) + assert.Equal(t, 5, process.authSchemaVersion()) + assert.Equal(t, "", process.replicaSetName()) + + expectedMap := map[string]interface{}{"port": int32(util.MongoDbDefaultPort)} + assert.Equal(t, expectedMap, process.EnsureNetConfig()) +} + +func TestCreateProcessWithNoTLSEnabled(t *testing.T) { + process := NewMongodProcessAppDB("no-tls-process-0", "no-tls-process-0.cluster.local", defaultMongoDBAppDBVersioned("4.0.5")) + + args := process["args2_6"].(map[string]interface{}) + net := args["net"].(map[string]interface{}) + assert.Nil(t, net["ssl"]) +} + +func TestCreateProcessWithTLSEnabled(t *testing.T) { + appdb := defaultMongoDBAppDBVersioned("4.0.5") + appdb.Security = &mdbv1.Security{ + TLSConfig: &mdbv1.TLSConfig{Enabled: true}, + } + + process := NewMongodProcessAppDB("tls-process-1", "tls-process-1.cluster.local", appdb) + + pemKeyFile := "/var/lib/mongodb-automation/secrets/certs/tls-process-1-pem" + expectedMap := map[string]interface{}{"port": int32(27017), "tls": map[string]interface{}{"mode": "requireTLS", "certificateKeyFile": pemKeyFile}} + args := process["args2_6"].(map[string]interface{}) + net := args["net"] + assert.Equal(t, expectedMap, net) +} diff --git a/controllers/om/automation_config.go b/controllers/om/automation_config.go new file mode 100644 index 000000000..f5ffcd865 --- /dev/null +++ b/controllers/om/automation_config.go @@ -0,0 +1,445 @@ +package om + +import ( + "encoding/json" + + "github.com/google/go-cmp/cmp" + + "k8s.io/apimachinery/pkg/api/equality" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/ldap" + "github.com/10gen/ops-manager-kubernetes/pkg/util/generate" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/spf13/cast" +) + +// AutomationConfig maintains the raw map in the Deployment field +// and constructs structs to make use of go's type safety +// Dev notes: actually, this object is just a wrapper for the `Deployment` object which is received from Ops Manager +// and it's not equal to the AutomationConfig object from mms! It contains some transient struct fields for easier +// configuration which are merged into the `Deployment` object before sending it back to Ops Manager +type AutomationConfig struct { + Auth *Auth + AgentSSL *AgentSSL + Deployment Deployment + Ldap *ldap.Ldap +} + +// applyInto merges the state of all concrete structs into the Deployment (map[string]interface{}) +func (a *AutomationConfig) Apply() error { + return applyInto(*a, &a.Deployment) +} + +// applyInto is a helper method that does not mutate AutomationConfig "a", but only Deployment "deployment" +func applyInto(a AutomationConfig, into *Deployment) error { + // applies all changes made to the Auth struct and merges with the corresponding map[string]interface{} + // inside the Deployment + if _, ok := a.Deployment["auth"]; ok { + mergedAuth, err := util.MergeWith(a.Auth, a.Deployment["auth"].(map[string]interface{}), &util.AutomationConfigTransformer{}) + if err != nil { + return err + } + (*into)["auth"] = mergedAuth + } + // the same applies for the ssl object and map + if _, ok := a.Deployment["tls"]; ok { + mergedTLS, err := util.MergeWith(a.AgentSSL, a.Deployment["tls"].(map[string]interface{}), &util.AutomationConfigTransformer{}) + if err != nil { + return err + } + (*into)["tls"] = mergedTLS + } + + if _, ok := a.Deployment["ldap"]; ok { + mergedLdap, err := util.MergeWith(a.Ldap, a.Deployment["ldap"].(map[string]interface{}), &util.AutomationConfigTransformer{}) + if err != nil { + return err + } + (*into)["ldap"] = mergedLdap + } + return nil +} + +// EqualsWithoutDeployment returns true if two AutomationConfig objects are meaningful equal by following the following conditions: +// - Not taking AutomationConfig.Deployment into consideration. +// - Serializing ac A and ac B to ensure that we remove util.MergoDelete before comparing those two. +// +// Comparing Deployments will not work correctly in current AutomationConfig implementation. Helper +// structs, such as AutomationConfig.AgentSSL or AutomationConfig.Auth use non-pointer fields (without `omitempty`). +// When merging them into AutomationConfig.deployment, JSON unmarshaller renders them into their representations, +// and they get into the final result. Sadly, some tests (especially TestLDAPIsMerged) relies on this behavior. +func (a *AutomationConfig) EqualsWithoutDeployment(b AutomationConfig) bool { + deploymentsComparer := cmp.Comparer(func(x, y Deployment) bool { + return true + }) + + acA, err := getSerializedAC(*a) + if err != nil { + return false + } + + acB, err := getSerializedAC(b) + if err != nil { + return false + } + + return cmp.Equal(acA, acB, deploymentsComparer) +} + +// getSerializedAC calls apply on a deepCopy which decodes the internal struct into the Deployment map. After decoding, +// we encode the map into its internal representation again. Doing that removes util.MergoDelete for proper comparison +func getSerializedAC(original AutomationConfig) (AutomationConfig, error) { + empty := AutomationConfig{} + deepCopy, err := util.MapDeepCopy(original.Deployment) + if err != nil { + return empty, err + } + err = applyInto(original, (*Deployment)(&deepCopy)) + if err != nil { + return empty, err + } + + ac, err := BuildAutomationConfigFromDeployment(deepCopy) + if err != nil { + return empty, err + } + return *ac, nil +} + +// isEqualAfterModification returns true if two Deployment objects are equal ignoring their underlying custom types. +// depFunc might change the Deployment or might only change the types. In both cases it will fail the comparison +// as long as we don't ignore the types. +func isEqualAfterModification(changeDeploymentFunc func(Deployment) error, deployment Deployment) (bool, error) { + original, err := util.MapDeepCopy(deployment) // original over the wire does not contain any types + if err != nil { + return false, err + } + if err := changeDeploymentFunc(deployment); err != nil { // might change types as well + return false, err + } + + deploymentWithoutTypes := map[string]interface{}{} + b, err := json.Marshal(deployment) + if err != nil { + return false, err + } + err = json.Unmarshal(b, &deploymentWithoutTypes) + if err != nil { + return false, err + } + + if equality.Semantic.DeepEqual(deploymentWithoutTypes, original) { + return true, nil + } + return false, nil +} + +// NewAutomationConfig returns an AutomationConfig instance with all reference +// types initialized with non nil values +func NewAutomationConfig(deployment Deployment) *AutomationConfig { + return &AutomationConfig{AgentSSL: &AgentSSL{}, Auth: NewAuth(), Deployment: deployment} +} + +// NewAuth returns an empty Auth reference with all reference types initialised to non nil values +func NewAuth() *Auth { + return &Auth{ + KeyFile: util.AutomationAgentKeyFilePathInContainer, + KeyFileWindows: util.AutomationAgentWindowsKeyFilePath, + Users: make([]*MongoDBUser, 0), + AutoAuthMechanisms: make([]string, 0), + DeploymentAuthMechanisms: make([]string, 0), + AutoAuthMechanism: "MONGODB-CR", + Disabled: true, + AuthoritativeSet: true, + AutoUser: util.AutomationAgentName, + } +} + +// this is needed only for the cluster config file when we use a headless agent +func (a *AutomationConfig) SetVersion(configVersion int64) *AutomationConfig { + a.Deployment["version"] = configVersion + return a +} + +// this is needed only for the cluster config file when we use a headless agent +func (a *AutomationConfig) SetOptions(downloadBase string) *AutomationConfig { + a.Deployment["options"] = map[string]string{"downloadBase": downloadBase} + + return a +} + +// this is needed only for the cluster config file when we use a headless agent +func (a *AutomationConfig) SetMongodbVersions(versionConfigs []MongoDbVersionConfig) *AutomationConfig { + a.Deployment["mongoDbVersions"] = versionConfigs + + return a +} + +func (a *AutomationConfig) MongodbVersions() []MongoDbVersionConfig { + return a.Deployment["mongoDbVersions"].([]MongoDbVersionConfig) +} + +// this is needed only for the cluster config file when we use a headless agent +func (a *AutomationConfig) SetBaseUrlForAgents(baseUrl string) *AutomationConfig { + for _, v := range a.Deployment.getBackupVersions() { + cast.ToStringMap(v)["baseUrl"] = baseUrl + } + for _, v := range a.Deployment.getMonitoringVersions() { + cast.ToStringMap(v)["baseUrl"] = baseUrl + } + return a +} + +func (a *AutomationConfig) Serialize() ([]byte, error) { + return a.Deployment.Serialize() +} + +type Auth struct { + // Users is a list which contains the desired users at the project level. + Users []*MongoDBUser `json:"usersWanted,omitempty"` + Disabled bool `json:"disabled"` + // AuthoritativeSet indicates if the MongoDBUsers should be synced with the current list of Users + AuthoritativeSet bool `json:"authoritativeSet"` + // AutoAuthMechanisms is a list of auth mechanisms the Automation Agent is able to use + AutoAuthMechanisms []string `json:"autoAuthMechanisms,omitempty"` + + // AutoAuthMechanism is the currently active agent authentication mechanism. This is a read only + // field + AutoAuthMechanism string `json:"autoAuthMechanism"` + // DeploymentAuthMechanisms is a list of possible auth mechanisms that can be used within deployments + DeploymentAuthMechanisms []string `json:"deploymentAuthMechanisms,omitempty"` + // AutoUser is the MongoDB Automation Agent user, when x509 is enabled, it should be set to the subject of the AA's certificate + AutoUser string `json:"autoUser,omitempty"` + // Key is the contents of the KeyFile, the automation agent will ensure this a KeyFile with these contents exists at the `KeyFile` path + Key string `json:"key,omitempty"` + // KeyFile is the path to a keyfile with read & write permissions. It is a required field if `Disabled=false` + KeyFile string `json:"keyfile,omitempty"` + // KeyFileWindows is required if `Disabled=false` even if the value is not used + KeyFileWindows string `json:"keyfileWindows,omitempty"` + // AutoPwd is a required field when going from `Disabled=false` to `Disabled=true` + AutoPwd string `json:"autoPwd,omitempty"` + // NewAutoPwd is used for rotating the agent password + NewAutoPwd string `json:"newAutoPwd,omitempty"` + // LdapGroupDN is required when enabling LDAP authz and agents authentication on $external + LdapGroupDN string `json:"autoLdapGroupDN,omitempty"` +} + +// IsEnabled is a convenience function to aid readability +func (a *Auth) IsEnabled() bool { + return !a.Disabled +} + +// Enable is a convenience function to aid readability +func (a *Auth) Enable() { + a.Disabled = false +} + +// AddUser updates the Users list with the specified user +func (a *Auth) AddUser(user MongoDBUser) { + a.Users = append(a.Users, &user) +} + +// HasUser returns true if a user exists with the specified username and password +// or false if the user does not exists +func (a *Auth) HasUser(username, db string) bool { + _, user := a.GetUser(username, db) + return user != nil +} + +// GetUser returns the index of the user with the given username and password +// and the user itself. -1 and a nil user are returned if the user does not exist +func (a *Auth) GetUser(username, db string) (int, *MongoDBUser) { + for i, u := range a.Users { + if u != nil && u.Username == username && u.Database == db { + return i, u + } + } + return -1, nil +} + +// UpdateUser accepts a user ad updates the corresponding existing user. +// the user to update is identified by user.Username and user.Database +func (a *Auth) UpdateUser(user MongoDBUser) bool { + i, foundUser := a.GetUser(user.Username, user.Database) + if foundUser == nil { + return false + } + a.Users[i] = &user + return true +} + +// EnsureUser adds the user to the Users list if it does not exist, +// it will update the existing user if it is already present. +func (a *Auth) EnsureUser(user MongoDBUser) { + if a.HasUser(user.Username, user.Database) { + a.UpdateUser(user) + } else { + a.AddUser(user) + } +} + +// EnsureUserRemoved will remove user of given username and password. A boolean +// indicating whether or not the underlying array was modified will be +// returned +func (a *Auth) EnsureUserRemoved(username, db string) bool { + if a.HasUser(username, db) { + a.RemoveUser(username, db) + return true + } + return false +} + +// RemoveUser assigns a nil value to the user. This nil value +// will flag this user for deletion when merging, see mergo_utils.go +func (a *Auth) RemoveUser(username, db string) { + i, _ := a.GetUser(username, db) + a.Users[i] = nil +} + +// AgentSSL contains fields related to configuration Automation +// Agent SSL & authentication. +type AgentSSL struct { + CAFilePath string `json:"CAFilePath,omitempty"` + AutoPEMKeyFilePath string `json:"autoPEMKeyFilePath,omitempty"` + ClientCertificateMode string `json:"clientCertificateMode,omitempty"` +} + +type MongoDBUser struct { + Mechanisms []string `json:"mechanisms"` + Roles []*Role `json:"roles"` + Username string `json:"user"` + Database string `json:"db"` + AuthenticationRestrictions []string `json:"authenticationRestrictions"` + + // The cleartext password to be assigned to the user + InitPassword string `json:"initPwd,omitempty"` + + // ScramShaCreds are generated by the operator. + ScramSha256Creds *ScramShaCreds `json:"scramSha256Creds"` + ScramSha1Creds *ScramShaCreds `json:"scramSha1Creds"` +} + +type ScramShaCreds struct { + IterationCount int `json:"iterationCount"` + Salt string `json:"salt"` + ServerKey string `json:"serverKey"` + StoredKey string `json:"storedKey"` +} + +func (u *MongoDBUser) AddRole(role *Role) { + u.Roles = append(u.Roles, role) +} + +type Role struct { + Role string `json:"role"` + Database string `json:"db"` +} + +type BuildConfig struct { + Platform string `json:"platform"` + Url string `json:"url"` + GitVersion string `json:"gitVersion"` + Architecture string `json:"architecture"` + Flavor string `json:"flavor"` + MinOsVersion string `json:"minOsVersion"` + MaxOsVersion string `json:"maxOsVersion"` + Modules []string `json:"modules"` + // Note, that we are not including all "windows" parameters like "Win2008plus" as such distros won't be used +} + +type MongoDbVersionConfig struct { + Name string `json:"name"` + Builds []*BuildConfig `json:"builds"` +} + +// EnsureKeyFileContents makes sure a valid keyfile is generated and used for internal cluster authentication +func (ac *AutomationConfig) EnsureKeyFileContents() error { + if ac.Auth.Key == "" || ac.Auth.Key == util.InvalidKeyFileContents { + keyfileContents, err := generate.KeyFileContents() + if err != nil { + return err + } + ac.Auth.Key = keyfileContents + } + return nil +} + +// EnsurePassword makes sure that there is an Automation Agent password +// that the agents will use to communicate with the deployments. The password +// is returned so it can be provided to the other agents +func (ac *AutomationConfig) EnsurePassword() (string, error) { + if ac.Auth.AutoPwd == "" || ac.Auth.AutoPwd == util.InvalidAutomationAgentPassword { + automationAgentBackupPassword, err := generate.KeyFileContents() + if err != nil { + return "", err + } + ac.Auth.AutoPwd = automationAgentBackupPassword + return automationAgentBackupPassword, nil + } + return ac.Auth.AutoPwd, nil +} + +func (ac *AutomationConfig) CanEnableX509ProjectAuthentication() (bool, string) { + if !ac.Deployment.AllProcessesAreTLSEnabled() { + return false, "not all processes are TLS enabled, unable to enable x509 authentication" + } + return true, "" +} + +func BuildAutomationConfigFromDeployment(deployment Deployment) (*AutomationConfig, error) { + finalAutomationConfig := &AutomationConfig{Deployment: deployment} + finalAutomationConfig.Auth = &Auth{} + + authMap, ok := deployment["auth"] + if ok { + authMarshalled, err := json.Marshal(authMap) + if err != nil { + return nil, err + } + auth := &Auth{} + if err := json.Unmarshal(authMarshalled, auth); err != nil { + return nil, err + } + finalAutomationConfig.Auth = auth + } + + tlsMap, ok := deployment["tls"] + if ok { + sslMarshalled, err := json.Marshal(tlsMap) + if err != nil { + return nil, err + } + ssl := &AgentSSL{} + if err := json.Unmarshal(sslMarshalled, ssl); err != nil { + return nil, err + } + finalAutomationConfig.AgentSSL = ssl + } + + ldapMap, ok := deployment["ldap"] + if ok { + ldapMarshalled, err := json.Marshal(ldapMap) + if err != nil { + return nil, err + } + ldap := &ldap.Ldap{} + if err := json.Unmarshal(ldapMarshalled, ldap); err != nil { + return nil, err + } + finalAutomationConfig.Ldap = ldap + } + + return finalAutomationConfig, nil +} + +// BuildAutomationConfigFromBytes takes in jsonBytes representing the Deployment +// and constructs an instance of AutomationConfig with all the concrete structs +// filled out. +func BuildAutomationConfigFromBytes(jsonBytes []byte) (*AutomationConfig, error) { + deployment, err := BuildDeploymentFromBytes(jsonBytes) + if err != nil { + return nil, err + } + return BuildAutomationConfigFromDeployment(deployment) +} diff --git a/controllers/om/automation_config_test.go b/controllers/om/automation_config_test.go new file mode 100644 index 000000000..bb312713b --- /dev/null +++ b/controllers/om/automation_config_test.go @@ -0,0 +1,884 @@ +package om + +import ( + "encoding/json" + "testing" + + "k8s.io/apimachinery/pkg/api/equality" + + "github.com/spf13/cast" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/ldap" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/stretchr/testify/assert" +) + +var originalAutomationConfig = *getTestAutomationConfig() + +func getTestAutomationConfig() *AutomationConfig { + a, _ := BuildAutomationConfigFromBytes(loadBytesFromTestData("automation_config.json")) + return a +} + +func TestScramShaCreds_AreRemovedCorrectly(t *testing.T) { + ac := getTestAutomationConfig() + user := ac.Auth.Users[0] + user.ScramSha256Creds = nil + + if err := ac.Apply(); err != nil { + t.Fatal(err) + } + + deploymentUser := getUser(ac.Deployment, 0) + assert.NotContains(t, deploymentUser, "scramSha256Creds") +} + +func TestUntouchedFieldsAreNotDeleted(t *testing.T) { + a := getTestAutomationConfig() + a.Auth.AutoUser = "some-user" + a.AgentSSL.ClientCertificateMode = util.RequireClientCertificates + + if err := a.Apply(); err != nil { + t.Fatal(err) + } + + auth := cast.ToStringMap(a.Deployment["auth"]) + originalAuth := cast.ToStringMap(originalAutomationConfig.Deployment["auth"]) + + // ensure values aren't overridden + assert.Equal(t, auth["usersDeleted"], originalAuth["usersDeleted"]) + assert.Equal(t, auth["autoAuthMechanisms"], originalAuth["autoAuthMechanisms"]) + assert.Equal(t, auth["autoAuthRestrictions"], originalAuth["autoAuthRestrictions"]) + assert.Equal(t, auth["disabled"], originalAuth["disabled"]) + assert.Equal(t, auth["usersWanted"], originalAuth["usersWanted"]) + + // ensure values we specified are overridden + assert.Equal(t, auth["autoUser"], "some-user") + tls := cast.ToStringMap(a.Deployment["tls"]) + assert.Equal(t, tls["clientCertificateMode"], util.RequireClientCertificates) + + // ensures fields in nested fields we don't know about are retained + scramSha256Creds := cast.ToStringMap(getUser(a.Deployment, 0)["scramSha256Creds"]) + assert.Equal(t, float64(15000), scramSha256Creds["iterationCount"]) + assert.Equal(t, "I570PanWIx1eNUTo7j4ROl2/zIqMsVd6CcIE+A==", scramSha256Creds["salt"]) + assert.Equal(t, "M4/jskiMM0DpvG/qgMELWlfReqV2ZmwdU8+vJZ/4prc=", scramSha256Creds["serverKey"]) + assert.Equal(t, "m1dXf5hHJk7EOAAyJBxfsZvFx1HwtTdda6pFPm0BlOE=", scramSha256Creds["storedKey"]) + + // ensure values we know nothing about aren't touched + options := cast.ToStringMap(a.Deployment["options"]) + assert.Equal(t, options["downloadBase"], "/var/lib/mongodb-mms-automation") + assert.Equal(t, options["downloadBaseWindows"], "%SystemDrive%\\MMSAutomation\\versions") +} + +func TestUserIsAddedToTheEnd(t *testing.T) { + a := getTestAutomationConfig() + + a.Auth.AddUser(MongoDBUser{ + Database: "my-db", + Username: "my-user", + Roles: []*Role{{Role: "my-role", Database: "role-db"}}, + }) + + if err := a.Apply(); err != nil { + t.Fatal(err) + } + + assert.Len(t, getUsers(a.Deployment), 4) + + lastUser := getUser(a.Deployment, 3) + + assert.Equal(t, "my-db", lastUser["db"]) + assert.Equal(t, "my-user", lastUser["user"]) + roles := cast.ToSlice(lastUser["roles"]) + role := cast.ToStringMap(roles[0]) + assert.Equal(t, "my-role", role["role"]) + assert.Equal(t, "role-db", role["db"]) + +} + +func TestUserIsUpdated_AndOtherUsersDontGetAffected(t *testing.T) { + ac := getTestAutomationConfig() + + originalUser := getUser(ac.Deployment, 0) + + assert.Equal(t, "testDb0", originalUser["db"]) + assert.Equal(t, "testUser0", originalUser["user"]) + + // change the fields on the struct + user := ac.Auth.Users[0] + + // struct fields should be read correctly + assert.Equal(t, "testDb0", user.Database) + assert.Equal(t, "testUser0", user.Username) + + user.Database = "new-db" + user.Username = "new-user" + + if err := ac.Apply(); err != nil { + t.Fatal(err) + } + + userFromDep := getUser(ac.Deployment, 0) + assert.Equal(t, "new-db", userFromDep["db"]) + assert.Equal(t, "new-user", userFromDep["user"]) + + allUsers := getUsers(ac.Deployment) + for _, user := range allUsers[1:] { + userMap := cast.ToStringMap(user) + assert.NotEqual(t, "new-db", userMap["db"]) + assert.NotEqual(t, "new-user", userMap["user"]) + } +} + +func TestCanPrependUser(t *testing.T) { + ac := getTestAutomationConfig() + + newUser := &MongoDBUser{ + Database: "myDatabase", + Username: "myUsername", + AuthenticationRestrictions: []string{}, + Roles: []*Role{ + { + Role: "myRole", + Database: "myDb", + }, + }, + } + + assert.Len(t, getUsers(ac.Deployment), 3) + assert.Len(t, ac.Auth.Users, 3) + + ac.Auth.Users = append([]*MongoDBUser{newUser}, ac.Auth.Users...) + + if err := ac.Apply(); err != nil { + t.Fatal(err) + } + + dep := ac.Deployment + + firstUser := getUser(dep, 0) + firstUsersRole := getRole(dep, 0, 0) + secondUser := getUser(dep, 1) + secondUsersRoles := getRoles(dep, 1) + + // the user added to the start of the list should be the first element + assert.Equal(t, "myUsername", firstUser["user"]) + assert.Equal(t, "myDatabase", firstUser["db"]) + + // it should have the single role provided + assert.Equal(t, "myRole", firstUsersRole["role"]) + assert.Equal(t, "myDb", firstUsersRole["db"]) + + // the already existing user should be the second element + assert.Equal(t, "testUser0", secondUser["user"]) + assert.Equal(t, "testDb0", secondUser["db"]) + + assert.Len(t, secondUsersRoles, 3, "second user should not have an additional role") + + // already existing user should not have been granted the additional role + for _, omRoleInterface := range secondUsersRoles { + omRoleMap := cast.ToStringMap(omRoleInterface) + assert.False(t, omRoleMap["db"] == "myDb") + assert.False(t, omRoleMap["role"] == "myRole") + } + + allUsers := getUsers(ac.Deployment) + for _, user := range allUsers[1:] { + userMap := cast.ToStringMap(user) + assert.NotEqual(t, "new-db", userMap["db"]) + assert.NotEqual(t, "new-user", userMap["user"]) + } +} + +func TestUserIsDeleted(t *testing.T) { + a := getTestAutomationConfig() + + a.Auth.Users[1] = nil + a.Auth.Users[2] = nil + + if err := a.Apply(); err != nil { + t.Fatal(err) + } + + assert.Len(t, getUsers(a.Deployment), 1) +} + +func TestUnknownFields_AreNotMergedWithOtherElements(t *testing.T) { + ac := getTestAutomationConfig() + + userToDelete := getUser(ac.Deployment, 1) + assert.Contains(t, userToDelete, "unknownFieldOne") + assert.Contains(t, userToDelete, "unknownFieldTwo") + + ac.Auth.Users[1] = nil + + if err := ac.Apply(); err != nil { + t.Fatal(err) + } + + users := getUsers(ac.Deployment) + + // user got removed + assert.Len(t, users, 2) + + // other users didn't accidentally get unknown fields merged into them + for _, user := range users { + userMap := cast.ToStringMap(user) + assert.NotContains(t, userMap, "unknownFieldOne") + assert.NotContains(t, userMap, "unknownFieldTwo") + } + +} + +func TestSettingFieldInListToNil_RemovesElement(t *testing.T) { + ac := getTestAutomationConfig() + + ac.Auth.Users[1] = nil + + if err := ac.Apply(); err != nil { + t.Fatal(err) + } + + assert.Len(t, getUsers(ac.Deployment), 2) +} + +func TestRoleIsAddedToTheEnd(t *testing.T) { + ac := getTestAutomationConfig() + + roles := getRoles(ac.Deployment, 0) + assert.Len(t, roles, 3) + + ac.Auth.Users[0].AddRole(&Role{ + Database: "admin", + Role: "some-new-role", + }) + + if err := ac.Apply(); err != nil { + t.Fatal(err) + } + + roles = getRoles(ac.Deployment, 0) + assert.Len(t, roles, 4) + + lastRole := cast.ToStringMap(roles[len(roles)-1]) + + assert.Equal(t, "admin", lastRole["db"]) + assert.Equal(t, "some-new-role", lastRole["role"]) +} + +func TestRoleIsUpdated(t *testing.T) { + ac := getTestAutomationConfig() + + originalRole := getRole(ac.Deployment, 0, 0) + + assert.Equal(t, "admin", originalRole["db"]) + assert.Equal(t, "backup", originalRole["role"]) + + role := ac.Auth.Users[0].Roles[0] + role.Database = "updated-db" + role.Role = "updated-role" + + if err := ac.Apply(); err != nil { + assert.Fail(t, "Error applying changes") + } + + actualRole := getRole(ac.Deployment, 0, 0) + assert.Equal(t, "updated-role", actualRole["role"]) + assert.Equal(t, "updated-db", actualRole["db"]) +} + +func TestMiddleRoleIsCorrectlyDeleted(t *testing.T) { + a := getTestAutomationConfig() + + a.Auth.Users[0].Roles = remove(a.Auth.Users[0].Roles, 1) + + if err := a.Apply(); err != nil { + t.Fatal(err) + } + + roles := getRoles(a.Deployment, 0) + assert.Len(t, roles, 2) + + firstRole := cast.ToStringMap(roles[0]) + secondRole := cast.ToStringMap(roles[1]) + + // first role from automation_config.json + assert.Equal(t, "admin", firstRole["db"]) + assert.Equal(t, "backup", firstRole["role"]) + + // third role from automation_config.json + assert.Equal(t, "admin", secondRole["db"]) + assert.Equal(t, "automation", secondRole["role"]) + +} + +func TestAllRolesAreDeleted(t *testing.T) { + a := getTestAutomationConfig() + a.Auth.Users[0].Roles = []*Role{} + if err := a.Apply(); err != nil { + t.Fatal(err) + } + + roles := getRoles(a.Deployment, 0) + assert.Len(t, roles, 0) +} + +func TestRoleIsDeletedAndAppended(t *testing.T) { + a := getTestAutomationConfig() + + a.Auth.Users[0].Roles = remove(a.Auth.Users[0].Roles, 2) + + newRole := &Role{ + Database: "updated-db", + Role: "updated-role", + } + a.Auth.Users[0].Roles = append(a.Auth.Users[0].Roles, newRole) + + if err := a.Apply(); err != nil { + t.Fatal(err) + } + + actualRole := getRole(a.Deployment, 0, 2) + assert.Equal(t, "updated-role", actualRole["role"]) + assert.Equal(t, "updated-db", actualRole["db"]) +} + +func TestNoAdditionalFieldsAreAddedToAgentSSL(t *testing.T) { + ac := getTestAutomationConfig() + ac.AgentSSL = &AgentSSL{ + CAFilePath: util.MergoDelete, + ClientCertificateMode: util.OptionalClientCertficates, + AutoPEMKeyFilePath: util.MergoDelete, + } + + if err := ac.Apply(); err != nil { + t.Fatal(err) + } + + tls := cast.ToStringMap(ac.Deployment["tls"]) + assert.Contains(t, tls, "clientCertificateMode") + + assert.NotContains(t, tls, "autoPEMKeyFilePath") + assert.NotContains(t, tls, "CAFilePath") +} + +func TestCanResetAgentSSL(t *testing.T) { + ac := getTestAutomationConfig() + ac.AgentSSL = &AgentSSL{ + ClientCertificateMode: util.OptionalClientCertficates, + CAFilePath: util.CAFilePathInContainer, + AutoPEMKeyFilePath: util.AutomationAgentPemFilePath, + } + + if err := ac.Apply(); err != nil { + t.Fatal(err) + } + + tls := cast.ToStringMap(ac.Deployment["tls"]) + assert.Equal(t, tls["clientCertificateMode"], util.OptionalClientCertficates) + assert.Equal(t, tls["autoPEMKeyFilePath"], util.AutomationAgentPemFilePath) + assert.Equal(t, tls["CAFilePath"], util.CAFilePathInContainer) + + ac.AgentSSL = &AgentSSL{ + CAFilePath: util.MergoDelete, + AutoPEMKeyFilePath: util.MergoDelete, + ClientCertificateMode: util.OptionalClientCertficates, + } + + if err := ac.Apply(); err != nil { + t.Fatal(err) + } + + tls = cast.ToStringMap(ac.Deployment["tls"]) + assert.Equal(t, tls["clientCertificateMode"], util.OptionalClientCertficates) + assert.NotContains(t, tls, "autoPEMKeyFilePath") + assert.NotContains(t, tls, "CAFilePath") +} + +func TestVersionsAndBuildsRetained(t *testing.T) { + ac := getTestAutomationConfig() + + if err := ac.Apply(); err != nil { + t.Fatal(err) + } + + dep := ac.Deployment + versions := getMongoDbVersions(dep) + assert.Len(t, versions, 2) + + // ensure no elements lost from array + builds1 := getVersionBuilds(dep, 0) + builds2 := getVersionBuilds(dep, 1) + + // ensure the correct number of elements in each nested array + assert.Len(t, builds1, 6) + assert.Len(t, builds2, 11) + + /* + { + "architecture": "amd64", + "bits": 64, + "flavor": "suse", + "gitVersion": "45d947729a0315accb6d4f15a6b06be6d9c19fe7", + "maxOsVersion": "12", + "minOsVersion": "11", + "modules": [ + "enterprise" + ], + "platform": "linux", + "url": "https://downloads.mongodb.com/linux/mongodb-linux-x86_64-enterprise-suse11-3.2.0.tgz" + } + */ + // ensure correct values for nested fields + build1 := getVersionBuild(dep, 1, 4) + assert.Equal(t, "amd64", build1["architecture"]) + assert.Equal(t, float64(64), build1["bits"]) + assert.Equal(t, "suse", build1["flavor"]) + assert.Equal(t, "45d947729a0315accb6d4f15a6b06be6d9c19fe7", build1["gitVersion"]) + assert.Equal(t, "12", build1["maxOsVersion"]) + assert.Equal(t, "11", build1["minOsVersion"]) + assert.Equal(t, "linux", build1["platform"]) + assert.Equal(t, "https://downloads.mongodb.com/linux/mongodb-linux-x86_64-enterprise-suse11-3.2.0.tgz", build1["url"]) + + // nested list maintains untouched + modulesList := build1["modules"].([]interface{}) + assert.Equal(t, "enterprise", modulesList[0]) +} + +func TestMergoDeleteWorksInNestedMapsWithFieldsNotReturnedByAutomationConfig(t *testing.T) { + ac := getTestAutomationConfig() + + ac.Auth.Users[0].Database = util.MergoDelete + + if err := ac.Apply(); err != nil { + t.Fatal(err) + } + + user := getUser(ac.Deployment, 0) + + assert.NotContains(t, user, "db") // specify value to delete, it does not remain in final map + assert.Contains(t, user, "user") // value untouched remains +} + +func TestDeletionOfMiddleElements(t *testing.T) { + ac := getTestAutomationConfig() + + ac.Auth.AddUser(MongoDBUser{ + Database: "my-db", + Username: "my-user", + Roles: []*Role{{Role: "my-role", Database: "role-db"}}, + }) + + ac.Auth.AddUser(MongoDBUser{ + Database: "my-db-1", + Username: "my-user-1", + Roles: []*Role{{Role: "my-role", Database: "role-db"}}, + }) + + ac.Auth.AddUser(MongoDBUser{ + Database: "my-db-2", + Username: "my-user-2", + Roles: []*Role{{Role: "my-role", Database: "role-db"}}, + }) + + if err := ac.Apply(); err != nil { + t.Fatal(err) + } + + assert.Len(t, getUsers(ac.Deployment), 6) + + // remove the 3rd element of the list + ac.Auth.Users[2] = nil + + if err := ac.Apply(); err != nil { + t.Fatal(err) + } + + assert.Len(t, getUsers(ac.Deployment), 5) + + users := getUsers(ac.Deployment) + lastUser := cast.ToStringMap(users[len(users)-1]) + assert.Equal(t, lastUser["user"], "my-user-2") + assert.Equal(t, lastUser["db"], "my-db-2") + + // my-user-1 was correctly removed from between the other two elements + secondLastUser := cast.ToStringMap(users[len(users)-2]) + assert.Equal(t, "my-user-1", secondLastUser["user"]) + assert.Equal(t, "my-db-1", secondLastUser["db"]) + +} + +func TestDeleteLastElement(t *testing.T) { + ac := getTestAutomationConfig() + ac.Auth.Users[len(ac.Auth.Users)-1] = nil + + if err := ac.Apply(); err != nil { + t.Fatal(err) + } + + users := getUsers(ac.Deployment) + assert.Len(t, users, 2) + + lastUser := cast.ToStringMap(users[1]) + assert.Equal(t, "testDb1", lastUser["db"]) + assert.Equal(t, "testUser1", lastUser["user"]) + +} + +func TestCanDeleteUsers_AndAddNewOnes_InSingleOperation(t *testing.T) { + ac := getTestAutomationConfig() + + for i := range ac.Auth.Users { + ac.Auth.Users[i] = nil + } + + ac.Auth.AddUser(MongoDBUser{ + Database: "my-added-db", + Username: "my-added-user", + Roles: []*Role{}, + }) + + if err := ac.Apply(); err != nil { + t.Fatal(err) + } + + users := getUsers(ac.Deployment) + assert.Len(t, users, 1) + + addedUser := cast.ToStringMap(users[0]) + assert.Equal(t, "my-added-db", addedUser["db"]) + assert.Equal(t, "my-added-user", addedUser["user"]) +} + +func TestOneUserDeleted_OneUserUpdated(t *testing.T) { + ac := getTestAutomationConfig() + ac.Auth.Users[1] = nil + ac.Auth.Users[2].Database = "updated-database" + + if err := ac.Apply(); err != nil { + t.Fatal(err) + } + + users := getUsers(ac.Deployment) + + assert.Len(t, users, 2) + updatedUser := cast.ToStringMap(users[1]) + assert.Equal(t, "updated-database", updatedUser["db"]) +} + +func TestAssigningListsReassignsInDeployment(t *testing.T) { + ac := getTestAutomationConfig() + + ac.Auth.AutoAuthMechanisms = append(ac.Auth.AutoAuthMechanisms, "one", "two", "three") + + if err := ac.Apply(); err != nil { + t.Fatal(err) + } + + auth := cast.ToStringMap(ac.Deployment["auth"]) + authMechanisms := cast.ToSlice(auth["autoAuthMechanisms"]) + assert.Len(t, authMechanisms, 3) + + ac.Auth.AutoAuthMechanisms = []string{"two"} + + if err := ac.Apply(); err != nil { + t.Fatal(err) + } + + auth = cast.ToStringMap(ac.Deployment["auth"]) + authMechanisms = cast.ToSlice(auth["autoAuthMechanisms"]) + assert.Len(t, authMechanisms, 1) + assert.Contains(t, authMechanisms, "two") + + ac.Auth.AutoAuthMechanisms = []string{} + + if err := ac.Apply(); err != nil { + t.Fatal(err) + } + + auth = cast.ToStringMap(ac.Deployment["auth"]) + authMechanisms = cast.ToSlice(auth["autoAuthMechanisms"]) + assert.Len(t, authMechanisms, 0) +} + +func TestAutomationConfigEquality(t *testing.T) { + deployment1 := NewDeployment() + deployment1.setReplicaSets([]ReplicaSet{NewReplicaSet("1", "5.0.0")}) + + deployment2 := NewDeployment() + deployment2.setReplicaSets([]ReplicaSet{NewReplicaSet("2", "5.0.0")}) + + authConfig := Auth{ + Users: []*MongoDBUser{ + { + Roles: []*Role{ + { + Role: "root", + Database: "db1", + }, + }, + }, + }, + Disabled: false, + } + authConfig2 := authConfig + + agentSSLConfig := AgentSSL{ + CAFilePath: "/tmp/mypath", + } + agentSSLConfig2 := agentSSLConfig + + ldapConfig := ldap.Ldap{ + Servers: "server1", + } + ldapConfig2 := ldapConfig + + tests := map[string]struct { + a *AutomationConfig + b *AutomationConfig + expectedEquality bool + }{ + "Two empty configs are equal": { + a: &AutomationConfig{}, + b: &AutomationConfig{}, + expectedEquality: true, + }, + "Two different configs are not equal": { + a: getTestAutomationConfig(), + b: &AutomationConfig{}, + expectedEquality: false, + }, + "Two different configs are equal apart from the deployment": { + a: &AutomationConfig{ + Deployment: deployment1, + }, + b: &AutomationConfig{ + Deployment: deployment2, + }, + expectedEquality: true, + }, + "Two the same configs created using the same structs are the same": { + a: &AutomationConfig{ + Auth: &authConfig, + AgentSSL: &agentSSLConfig, + Deployment: deployment1, + Ldap: &ldapConfig, + }, + b: &AutomationConfig{ + Auth: &authConfig, + AgentSSL: &agentSSLConfig, + Deployment: deployment1, + Ldap: &ldapConfig, + }, + expectedEquality: true, + }, + "Two the same configs created using deep copy (and structs with different addresses) are the same": { + a: &AutomationConfig{ + Auth: &authConfig, + AgentSSL: &agentSSLConfig, + Ldap: &ldapConfig, + }, + b: &AutomationConfig{ + Auth: &authConfig2, + AgentSSL: &agentSSLConfig2, + Ldap: &ldapConfig2, + }, + expectedEquality: true, + }, + "Same configs, except for MergoDelete, which is ignored": { + a: &AutomationConfig{ + Auth: &Auth{ + NewAutoPwd: util.MergoDelete, + LdapGroupDN: "abc", + }, + Ldap: &ldapConfig, + }, + b: &AutomationConfig{ + Auth: &Auth{ + LdapGroupDN: "abc", + }, + AgentSSL: &AgentSSL{ + AutoPEMKeyFilePath: util.MergoDelete, + }, + Ldap: &ldapConfig2, + }, + expectedEquality: true, + }, + } + for testName, testParameters := range tests { + t.Run(testName, func(t *testing.T) { + result := testParameters.a.EqualsWithoutDeployment(*testParameters.b) + assert.Equalf(t, testParameters.expectedEquality, result, "Expected %v, got %v", testParameters.expectedEquality, result) + }) + } +} + +func getUsers(deployment map[string]interface{}) []interface{} { + auth := deployment["auth"].(map[string]interface{}) + if users, ok := auth["usersWanted"]; ok { + return users.([]interface{}) + } + return make([]interface{}, 0) +} + +func getUser(deployment map[string]interface{}, i int) map[string]interface{} { + users := getUsers(deployment) + return users[i].(map[string]interface{}) +} + +func getRoles(deployment map[string]interface{}, userIdx int) []interface{} { + user := getUser(deployment, userIdx) + return user["roles"].([]interface{}) +} + +func getRole(deployment map[string]interface{}, userIdx, roleIdx int) map[string]interface{} { + roles := getRoles(deployment, userIdx) + return roles[roleIdx].(map[string]interface{}) +} + +func remove(slice []*Role, i int) []*Role { + copy(slice[i:], slice[i+1:]) + return slice[:len(slice)-1] +} + +func getMongoDbVersions(deployment map[string]interface{}) []interface{} { + return deployment["mongoDbVersions"].([]interface{}) +} + +func getVersionBuilds(deployment map[string]interface{}, versionIndex int) []interface{} { + versions := deployment["mongoDbVersions"].([]interface{}) + return versions[versionIndex].(map[string]interface{})["builds"].([]interface{}) +} + +func getVersionBuild(deployment map[string]interface{}, versionIndex, buildIndex int) map[string]interface{} { + return getVersionBuilds(deployment, versionIndex)[buildIndex].(map[string]interface{}) +} + +func TestLDAPIsMerged(t *testing.T) { + ac := getTestAutomationConfig() + ac.Ldap = &ldap.Ldap{ + AuthzQueryTemplate: "AuthzQueryTemplate", + BindMethod: "", + BindQueryUser: "BindQueryUser", + BindSaslMechanisms: "BindSaslMechanisms", + Servers: "", + TransportSecurity: "TransportSecurity", + UserToDnMapping: "UserToDnMapping", + ValidateLDAPServerConfig: false, + BindQueryPassword: "", + TimeoutMS: 1000, + UserCacheInvalidationInterval: 60, + CaFileContents: "", + } + if err := ac.Apply(); err != nil { + t.Fatal(err) + } + ldapMap := cast.ToStringMap(ac.Deployment["ldap"]) + assert.Equal(t, "AuthzQueryTemplate", ldapMap["authzQueryTemplate"]) + assert.Equal(t, "BindQueryUser", ldapMap["bindQueryUser"]) + assert.Equal(t, "BindSaslMechanisms", ldapMap["bindSaslMechanisms"]) + assert.Equal(t, "TransportSecurity", ldapMap["transportSecurity"]) + // ldap.Ldap is being merged by marshalling it to a map first, so ints end up as float64 + assert.Equal(t, float64(1000), ldapMap["timeoutMS"]) + assert.Equal(t, float64(60), ldapMap["userCacheInvalidationInterval"]) + // ensure zero value fields are added + assert.Contains(t, ldapMap, "bindMethod") + assert.Contains(t, ldapMap, "servers") + assert.Contains(t, ldapMap, "validateLDAPServerConfig") + assert.Contains(t, ldapMap, "bindQueryPassword") + assert.Contains(t, ldapMap, "CAFileContents") +} + +func TestApplyInto(t *testing.T) { + config := AutomationConfig{ + Auth: NewAuth(), + AgentSSL: &AgentSSL{ + CAFilePath: util.MergoDelete, + ClientCertificateMode: "test", + }, + Deployment: Deployment{"tls": map[string]interface{}{"test": ""}}, + Ldap: nil, + } + deepCopy := Deployment{"tls": map[string]interface{}{}} + err := applyInto(config, &deepCopy) + assert.NoError(t, err) + + // initial config.Deployment did not change + assert.NotEqual(t, config.Deployment, deepCopy) + + // new deployment is the merge result of the previous config.Deployment + config + assert.Equal(t, Deployment{"tls": map[string]interface{}{"clientCertificateMode": "test", "test": ""}}, deepCopy) +} + +func changeTypes(deployment Deployment) error { + rs := deployment.getReplicaSets() + deployment.setReplicaSets(rs) + return nil +} +func TestIsEqual(t *testing.T) { + + type args struct { + depFunc func(Deployment) error + deployment Deployment + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "depFunc does not do anything", + args: args{ + depFunc: func(deployment Deployment) error { + return nil + }, + deployment: getDeploymentWithRSOverTheWire(t), + }, + want: true, + }, + { + name: "depFunc does changes types, but content does not change", + args: args{ + depFunc: changeTypes, + deployment: getDeploymentWithRSOverTheWire(t), + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := isEqualAfterModification(tt.args.depFunc, tt.args.deployment) + assert.NoError(t, err) + assert.Equalf(t, tt.want, got, "isEqualAfterModification(%v, %v)", tt.args.depFunc, tt.args.deployment) + }) + } +} + +// TestIsEqualNotWorkingWithTypeChanges is a test that shows that deep equality does not work if our depFunc changes +// the underlying types as we mostly do. +func TestIsEqualNotWorkingWithTypeChanges(t *testing.T) { + + t.Run("is not working", func(t *testing.T) { + overTheWire := getDeploymentWithRSOverTheWire(t) + + original, err := util.MapDeepCopy(overTheWire) + assert.NoError(t, err) + + _ = changeTypes(overTheWire) + + equal := equality.Semantic.DeepEqual(original, overTheWire) + assert.False(t, equal) + }) + +} + +func getDeploymentWithRSOverTheWire(t *testing.T) Deployment { + overTheWire := getTestAutomationConfig().Deployment + overTheWire.addReplicaSet(NewReplicaSet("rs-1", "3.2.0")) + overTheWire.addReplicaSet(NewReplicaSet("rs-2", "3.2.0")) + marshal, err := json.Marshal(overTheWire) // as we get it over the wire, we need to reflect that + assert.NoError(t, err) + err = json.Unmarshal(marshal, &overTheWire) + assert.NoError(t, err) + return overTheWire +} diff --git a/controllers/om/automation_status.go b/controllers/om/automation_status.go new file mode 100644 index 000000000..e2d4cb332 --- /dev/null +++ b/controllers/om/automation_status.go @@ -0,0 +1,78 @@ +package om + +import ( + "encoding/json" + "fmt" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/apierror" + "golang.org/x/xerrors" + + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "go.uber.org/zap" +) + +// AutomationStatus represents the status of automation agents registered with Ops Manager +type AutomationStatus struct { + GoalVersion int `json:"goalVersion"` + Processes []ProcessStatus `json:"processes"` +} + +// ProcessStatus status of the process and what's the last version achieved +type ProcessStatus struct { + Hostname string `json:"hostname"` + Name string `json:"name"` + LastGoalVersionAchieved int `json:"lastGoalVersionAchieved"` + Plan []string `json:"plan"` +} + +func buildAutomationStatusFromBytes(b []byte) (*AutomationStatus, error) { + as := &AutomationStatus{} + if err := json.Unmarshal(b, &as); err != nil { + return nil, err + } + + return as, nil +} + +// Waits until the agents for relevant processes reach their state +func WaitForReadyState(oc Connection, processNames []string, log *zap.SugaredLogger) error { + log.Infow("Waiting for MongoDB agents to reach READY state...", "processes", processNames) + + reachStateFunc := func() (string, bool) { + as, lastErr := oc.ReadAutomationStatus() + if lastErr != nil { + return fmt.Sprintf("Error reading Automation Agents status: %s", lastErr), false + } + + if checkAutomationStatusIsGoal(as, processNames) { + return "", true + } + + return "MongoDB agents haven't reached READY state", false + } + if !util.DoAndRetry(reachStateFunc, log, 30, 3) { + return apierror.New(xerrors.Errorf("automation agents haven't reached READY state during defined interval")) + } + log.Info("MongoDB agents have reached READY state") + return nil +} + +// CheckAutomationStatusIsGoal returns true if all the relevant processes are in Goal +// state. +// Note, that the function is quite tolerant to any situations except for non-matching goal state, for example +// if one of the requested processes doesn't exist in the list of OM status processes - this is considered as ok +// (maybe we are doing the scale down for the RS and some members were removed from OM manually - this is ok as the Operator +// will fix this later) +func checkAutomationStatusIsGoal(as *AutomationStatus, relevantProcesses []string) bool { + for _, p := range as.Processes { + if !stringutil.Contains(relevantProcesses, p.Name) { + continue + } + if p.LastGoalVersionAchieved != as.GoalVersion { + return false + } + } + return true +} diff --git a/controllers/om/backup/backup.go b/controllers/om/backup/backup.go new file mode 100644 index 000000000..b2c4dc9b5 --- /dev/null +++ b/controllers/om/backup/backup.go @@ -0,0 +1,159 @@ +package backup + +import ( + "fmt" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/apierror" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" + + "go.uber.org/zap" + "golang.org/x/xerrors" +) + +type MongoDbResourceType string + +const ( + ReplicaSetType MongoDbResourceType = "ReplicaSet" + ShardedClusterType MongoDbResourceType = "ShardedCluster" +) + +/* +for sharded cluster: + + { + "clusterName": "shannon", + "groupId": "5ba0c398a957713d7f8653bd", + "id": "5ba3d344a957713d7f8f43fd", + "lastHeartbeat": "2018-09-20T17:12:28Z", + "links": [ ... ], + "shardName": "shannon-0", + "typeName": "SHARDED_REPLICA_SET" + } + +for sharded cluster member: + + { + "clusterName": "shannon", + "groupId": "5ba0c398a957713d7f8653bd", + "id": "5ba4ec37a957713d7f9bcba0", + "lastHeartbeat": "2018-09-24T12:41:05Z", + "links": [ ... ], + "replicaSetName": "shannon-0", + "shardName": "shannon-0", + "typeName": "REPLICA_SET" + } + +for replica set: + + { + "clusterName": "liffey", + "groupId": "5ba0c398a957713d7f8653bd", + "id": "5ba8db64a957713d7fa5018b", + "lastHeartbeat": "2018-09-24T12:41:08Z", + "links": [ ... ], + "replicaSetName": "liffey", + "typeName": "REPLICA_SET" + } +*/ +type HostCluster struct { + ReplicaSetName string `json:"replicaSetName"` + ClusterName string `json:"clusterName"` + ShardName string `json:"shardName"` + TypeName string `json:"typeName"` +} + +type HostClusterReader interface { + ReadHostCluster(clusterID string) (*HostCluster, error) +} + +// StopBackupIfEnabled tries to find backup configuration for specified resource (can be Replica Set or Sharded Cluster - +// Ops Manager doesn't backup Standalones) and disable it. +func StopBackupIfEnabled(readUpdater ConfigHostReadUpdater, hostClusterReader HostClusterReader, name string, resourceType MongoDbResourceType, log *zap.SugaredLogger) error { + response, err := readUpdater.ReadBackupConfigs() + if err != nil { + // If the operator can't read BackupConfigs, it might indicate that the Pods were removed before establishing + // or activating monitoring for the deployment. But if this is a deletion process of the MDB resource, it needs + // to be removed anyway, so we are logging the Error and continuing. + // TODO: Discussion. To avoid removing dependant objects in a DELETE operation, a finalizer should be implemented + // This finalizer would be required to add a "delay" to the deletion of the StatefulSet waiting for monitoring + // to be activated at the project. + if v, ok := err.(*apierror.Error); ok { + if v.ErrorCode == "CANNOT_GET_BACKUP_CONFIG_INVALID_STATE" { + log.Warnf("Could not read backup configs for this deployment. Will continue with the removal of the objects. %s", err) + return nil + } + } + return err + } + + for _, config := range response.Configs { + l := log.With("cluster id", config.ClusterId) + + l.Debugw("Found backup/host config", "status", config.Status) + + // Any status except for inactive will result in API rejecting the deletion of resource - we need to disable backup + if config.Status != Inactive { + cluster, err := hostClusterReader.ReadHostCluster(config.ClusterId) + if err != nil { + l.Errorf("Failed to read information about HostCluster: %s", err) + } else { + l.Debugw("Read cluster information", "details", cluster) + } + + if cluster.ClusterName == name && + (resourceType == ReplicaSetType && cluster.TypeName == "REPLICA_SET" || + resourceType == ShardedClusterType && cluster.TypeName == "SHARDED_REPLICA_SET") { + err = disableBackup(readUpdater, config, l) + if err != nil { + return err + } + l.Infow("Disabled backup for host cluster in Ops Manager", "host cluster name", cluster.ClusterName) + } + } + } + return nil +} + +func disableBackup(readUpdater ConfigHostReadUpdater, backupConfig *Config, log *zap.SugaredLogger) error { + if backupConfig.Status == Started { + err := readUpdater.UpdateBackupStatus(backupConfig.ClusterId, Stopped) + if err != nil { + log.Errorf("Failed to stop backup for host cluster: %s", err) + } else { + if waitUntilBackupReachesStatus(readUpdater, backupConfig, Stopped, log) { + log.Debugw("Stopped backup for host cluster") + } else { + log.Warn("Failed to stop backup for host cluster in Ops Manager (timeout exhausted)") + } + } + } + // We try to terminate in any case (it will fail if the backup config is not stopped) + err := readUpdater.UpdateBackupStatus(backupConfig.ClusterId, Terminating) + if err != nil { + return err + } + if !waitUntilBackupReachesStatus(readUpdater, backupConfig, Inactive, log) { + return xerrors.Errorf("Failed to disable backup for host cluster in Ops Manager (timeout exhausted)") + } + return nil +} + +func waitUntilBackupReachesStatus(configReader ConfigReader, backupConfig *Config, status Status, log *zap.SugaredLogger) bool { + waitSeconds := env.ReadIntOrPanic(util.BackupDisableWaitSecondsEnv) + retries := env.ReadIntOrPanic(util.BackupDisableWaitRetriesEnv) + + backupStatusFunc := func() (string, bool) { + config, err := configReader.ReadBackupConfig(backupConfig.ClusterId) + if err != nil { + return fmt.Sprintf("Unable to read from OM API: %s", err), false + } + + if config.Status == status { + return "", true + } + return fmt.Sprintf("Current status: %s", config.Status), false + } + + return util.DoAndRetry(backupStatusFunc, log, retries, waitSeconds) +} diff --git a/controllers/om/backup/backup_config.go b/controllers/om/backup/backup_config.go new file mode 100644 index 000000000..c474eff1a --- /dev/null +++ b/controllers/om/backup/backup_config.go @@ -0,0 +1,65 @@ +package backup + +type Status string + +const ( + Inactive Status = "INACTIVE" + Started Status = "STARTED" + Stopped Status = "STOPPED" + Terminating Status = "TERMINATING" + wiredTigerStorageEngine string = "WIRED_TIGER" +) + +type ConfigReader interface { + // ReadBackupConfigs returns all host clusters registered in OM. If there's no backup enabled the status is supposed + // to be Inactive + ReadBackupConfigs() (*ConfigsResponse, error) + + // ReadBackupConfig reads an individual backup config by cluster id + ReadBackupConfig(clusterID string) (*Config, error) + + ReadSnapshotSchedule(clusterID string) (*SnapshotSchedule, error) +} + +// ConfigUpdater is something can update an existing Backup Config +type ConfigUpdater interface { + UpdateBackupConfig(config *Config) (*Config, error) + UpdateBackupStatus(clusterID string, status Status) error + UpdateSnapshotSchedule(clusterID string, schedule *SnapshotSchedule) error +} + +type ConfigHostReadUpdater interface { + ConfigReader + ConfigUpdater + HostClusterReader +} + +/* + { + "authMechanismName": "NONE", + "clusterId": "5ba4ec37a957713d7f9bcb9a", + "encryptionEnabled": false, + "excludedNamespaces": [], + "groupId": "5ba0c398a957713d7f8653bd", + "links": [ + ... + ], + "sslEnabled": false, + "statusName": "INACTIVE" + } +*/ +type ConfigsResponse struct { + Configs []*Config `json:"results"` +} + +type Config struct { + ClusterId string `json:"clusterId"` + EncryptionEnabled bool `json:"encryptionEnabled"` + ExcludedNamespaces []string `json:"excludedNamespaces"` + IncludedNamespaces []string `json:"includedNamespaces"` + Provisioned bool `json:"provisioned"` + Status Status `json:"statusName"` + StorageEngineName string `json:"storageEngineName"` + SyncSource string `json:"syncSource"` + ProjectId string `json:"groupId"` +} diff --git a/controllers/om/backup/daemonconfig.go b/controllers/om/backup/daemonconfig.go new file mode 100644 index 000000000..c2d77e183 --- /dev/null +++ b/controllers/om/backup/daemonconfig.go @@ -0,0 +1,35 @@ +package backup + +type DaemonConfig struct { + Machine MachineConfig `json:"machine"` + AssignmentEnabled bool `json:"assignmentEnabled"` + BackupJobsEnabled bool `json:"backupJobsEnabled"` + ResourceUsageEnabled bool `json:"resourceUsageEnabled"` + GarbageCollectionEnabled bool `json:"garbageCollectionEnabled"` + RestoreQueryableJobsEnabled bool `json:"restoreQueryableJobsEnabled"` + Configured bool `json:"configured"` + Labels []string `json:"labels"` +} + +type MachineConfig struct { + HeadRootDirectory string `json:"headRootDirectory"` + MachineHostName string `json:"machine"` +} + +// NewDaemonConfig creates the 'DaemonConfig' fully initialized +func NewDaemonConfig(hostName, headDbDir string, assignmentLabels []string) DaemonConfig { + return DaemonConfig{ + Machine: MachineConfig{ + HeadRootDirectory: headDbDir, + MachineHostName: hostName, + }, + AssignmentEnabled: true, + Labels: assignmentLabels, + BackupJobsEnabled: true, + ResourceUsageEnabled: true, + GarbageCollectionEnabled: true, + RestoreQueryableJobsEnabled: true, + // TODO is this ok to have daemon configured with may be lacking oplog stores for example? + Configured: true, + } +} diff --git a/controllers/om/backup/datastoreconfig.go b/controllers/om/backup/datastoreconfig.go new file mode 100644 index 000000000..a19266cd0 --- /dev/null +++ b/controllers/om/backup/datastoreconfig.go @@ -0,0 +1,66 @@ +package backup + +import ( + "fmt" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" +) + +type DataStoreConfigResponse struct { + DataStoreConfigs []DataStoreConfig `json:"results"` +} + +// DataStoreConfig corresponds to 'ApiBackupDataStoreConfigView' in mms. It's shared by all configs which relate to +// MongoDB (oplogStore, blockStore) +type DataStoreConfig struct { + // These are the fields managed by the Operator + Id string `json:"id"` + Uri string `json:"uri"` + UseSSL bool `json:"ssl"` + + // These are all the rest fields + LoadFactor int `json:"loadFactor,omitempty"` + WriteConcern string `json:"writeConcern,omitempty"` + UsedSize int64 `json:"usedSize,omitempty"` + AssignmentEnabled bool `json:"assignmentEnabled,omitempty"` + MaxCapacityGB int64 `json:"maxCapacityGB,omitempty"` + Labels []string `json:"labels"` + EncryptedCredentials bool `json:"encryptedCredentials,omitempty"` +} + +// NewDataStoreConfig returns the new 'DataStoreConfig' object initializing the default values +func NewDataStoreConfig(id, uri string, tls bool, assignmentLabels []string) DataStoreConfig { + ret := DataStoreConfig{ + Id: id, + Uri: uri, + UseSSL: tls, + + // Default values + AssignmentEnabled: true, + } + + // The assignment labels has been set in the CR - so the CR becomes a source of truth for them + if assignmentLabels != nil { + ret.Labels = assignmentLabels + } + + return ret +} + +func (s DataStoreConfig) Identifier() interface{} { + return s.Id +} + +// MergeIntoOpsManagerConfig performs the merge operation of the Operator config view ('s') into the OM owned one +// ('opsManagerConfig') +func (s DataStoreConfig) MergeIntoOpsManagerConfig(opsManagerConfig DataStoreConfig) DataStoreConfig { + opsManagerConfig.Id = s.Id + opsManagerConfig.Uri = s.Uri + opsManagerConfig.UseSSL = s.UseSSL + opsManagerConfig.Labels = s.Labels + return opsManagerConfig +} + +func (r DataStoreConfig) String() string { + return fmt.Sprintf("id: %s, uri: %s, ssl: %v", r.Id, util.RedactMongoURI(r.Uri), r.UseSSL) +} diff --git a/controllers/om/backup/group_config.go b/controllers/om/backup/group_config.go new file mode 100644 index 000000000..eec847b05 --- /dev/null +++ b/controllers/om/backup/group_config.go @@ -0,0 +1,48 @@ +package backup + +// GroupConfigReader reads the Group Backup Config +type GroupConfigReader interface { + // ReadGroupBackupConfig reads project level backup configuration + // See: https://www.mongodb.com/docs/ops-manager/v6.0/reference/api/admin/backup/groups/get-one-backup-group-configuration-by-id/ + ReadGroupBackupConfig() (GroupBackupConfig, error) +} + +// GroupConfigUpdater updates the existing Group Backup Config +type GroupConfigUpdater interface { + // UpdateGroupBackupConfig updates project level backup configuration + // See: https://www.mongodb.com/docs/ops-manager/v6.0/reference/api/admin/backup/groups/update-one-backup-group-configuration/ + UpdateGroupBackupConfig(config GroupBackupConfig) ([]byte, error) +} + +// DaemonFilter corresponds to the "daemonFilter" from the "Project Backup Jobs Configuration" from Ops Manager +// See: https://www.mongodb.com/docs/ops-manager/v6.0/reference/api/admin/backup/groups/update-one-backup-group-configuration/ +type DaemonFilter struct { + HeadRootDirectory *string `json:"headRootDirectory,omitempty"` + Machine *string `json:"machine,omitempty"` +} + +// OplogStoreFilter corresponds to the "oplogStoreFilter" from the "Project Backup Jobs Configuration" from Ops Manager +// See: https://www.mongodb.com/docs/ops-manager/v6.0/reference/api/admin/backup/groups/update-one-backup-group-configuration/ +type OplogStoreFilter struct { + Id *string `json:"id,omitempty"` + Type *string `json:"type,omitempty"` +} + +// SnapshotStoreFilter corresponds to the "snapshotStoreFilter" from the "Project Backup Jobs Configuration" from Ops Manager +// See: https://www.mongodb.com/docs/ops-manager/v6.0/reference/api/admin/backup/groups/update-one-backup-group-configuration/ +type SnapshotStoreFilter struct { + OplogStoreFilter `json:",inline"` +} + +// GroupBackupConfig corresponds to the "Project Backup Jobs Configuration" from Ops Manager +// See: https://www.mongodb.com/docs/ops-manager/v6.0/reference/api/admin/backup/groups/update-one-backup-group-configuration/ +type GroupBackupConfig struct { + DaemonFilter []DaemonFilter `json:"daemonFilter,omitempty"` + Id *string `json:"id,omitempty"` + KmipClientCertPassword *string `json:"kmipClientCertPassword,omitempty"` + KmipClientCertPath *string `json:"kmipClientCertPath,omitempty"` + LabelFilter []string `json:"labelFilter"` + OplogStoreFilter []OplogStoreFilter `json:"oplogStoreFilter,omitempty"` + SnapshotStoreFilter []SnapshotStoreFilter `json:"snapshotStoreFilter,omitempty"` + SyncStoreFilter []string `json:"syncStoreFilter,omitempty"` +} diff --git a/controllers/om/backup/mongodbresource_backup.go b/controllers/om/backup/mongodbresource_backup.go new file mode 100644 index 000000000..23f0f71c6 --- /dev/null +++ b/controllers/om/backup/mongodbresource_backup.go @@ -0,0 +1,296 @@ +package backup + +import ( + "reflect" + + v1 "github.com/10gen/ops-manager-kubernetes/api/v1" + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/api/v1/status" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/secrets" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/workflow" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "go.uber.org/zap" + "golang.org/x/xerrors" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" +) + +type ConfigReaderUpdater interface { + GetBackupSpec() *mdbv1.Backup + GetResourceType() mdbv1.ResourceType + GetResourceName() string + v1.CustomResourceReadWriter +} + +// EnsureBackupConfigurationInOpsManager updates the backup configuration based on the MongoDB resource +// specification. +func EnsureBackupConfigurationInOpsManager(mdb ConfigReaderUpdater, secretsReader secrets.SecretClient, projectId string, configReadUpdater ConfigHostReadUpdater, groupConfigReader GroupConfigReader, groupConfigUpdater GroupConfigUpdater, log *zap.SugaredLogger) (workflow.Status, []status.Option) { + if mdb.GetBackupSpec() == nil { + return workflow.OK(), nil + } + + desiredConfig := getMongoDBBackupConfig(mdb.GetBackupSpec(), projectId) + + configs, err := configReadUpdater.ReadBackupConfigs() + if err != nil { + return workflow.Failed(err), nil + } + + projectConfigs := configs.Configs + + if len(projectConfigs) == 0 { + return workflow.Pending("Waiting for backup configuration to be created in Ops Manager").WithRetry(10), nil + } + + err = ensureGroupConfig(mdb, secretsReader, groupConfigReader, groupConfigUpdater) + if err != nil { + return workflow.Failed(err), nil + } + + return ensureBackupConfigStatuses(mdb, projectConfigs, desiredConfig, log, configReadUpdater) +} + +func ensureGroupConfig(mdb ConfigReaderUpdater, secretsReader secrets.SecretClient, reader GroupConfigReader, updater GroupConfigUpdater) error { + if mdb.GetBackupSpec() == nil || (mdb.GetBackupSpec().AssignmentLabels == nil && mdb.GetBackupSpec().Encryption == nil) { + return nil + } + + assignmentLabels := mdb.GetBackupSpec().AssignmentLabels + kmip := mdb.GetBackupSpec().GetKmip() + + config, err := reader.ReadGroupBackupConfig() + if err != nil { + return err + } + + requiresUpdate := false + + if kmip != nil { + desiredPath := util.KMIPClientSecretsHome + "/" + kmip.Client.ClientCertificateSecretName(mdb.GetName()) + kmip.Client.ClientCertificateSecretKeyName() + if config.KmipClientCertPath == nil || desiredPath != *config.KmipClientCertPath { + config.KmipClientCertPath = &desiredPath + requiresUpdate = true + } + + // The password is optional, so we propagate the error only if something abnormal happens + kmipPasswordSecret, err := secretsReader.GetSecret(types.NamespacedName{ + Namespace: kmip.Client.ClientCertificatePasswordSecretName(mdb.GetName()), + Name: mdb.GetNamespace(), + }) + if err == nil { + desiredPassword := string(kmipPasswordSecret.Data[kmip.Client.ClientCertificatePasswordKeyName()]) + if config.KmipClientCertPassword == nil || desiredPassword != *config.KmipClientCertPassword { + config.KmipClientCertPassword = &desiredPassword + requiresUpdate = true + } + } else if !apiErrors.IsNotFound(err) { + return err + } + } + + if assignmentLabels != nil { + if config.LabelFilter == nil || !reflect.DeepEqual(config.LabelFilter, assignmentLabels) { + config.LabelFilter = mdb.GetBackupSpec().AssignmentLabels + requiresUpdate = true + } + } + + if requiresUpdate { + _, err = updater.UpdateGroupBackupConfig(config) + } + return err +} + +// ensureBackupConfigStatuses makes sure that every config in the project has reached the desired state. +func ensureBackupConfigStatuses(mdb ConfigReaderUpdater, projectConfigs []*Config, desiredConfig *Config, log *zap.SugaredLogger, configReadUpdater ConfigHostReadUpdater) (workflow.Status, []status.Option) { + result := workflow.OK() + + for _, config := range projectConfigs { + desiredConfig.ClusterId = config.ClusterId + + desiredStatus := getDesiredStatus(desiredConfig, config) + + cluster, err := configReadUpdater.ReadHostCluster(config.ClusterId) + + if err != nil { + return workflow.Failed(err), nil + } + + // There is one HostConfig per component of the deployment being backed up. + // E.g. a sharded cluster with 2 shards is composed of 4 backup configurations. + // 1x CONFIG_SERVER_REPLICA_SET (config server) + // 2x REPLICA_SET (each shard) + // 1x SHARDED_REPLICA_SET (the source of truth for sharded cluster configuration) + // Only the SHARDED_REPLICA_SET can be configured, we need to ensure that based on the cluster wide + // we care about we are only updating the config if the type and name are correct. + resourceType := MongoDbResourceType(mdb.GetResourceType()) + nameIsEqual := cluster.ClusterName == mdb.GetResourceName() + isReplicaSet := resourceType == ReplicaSetType && cluster.TypeName == "REPLICA_SET" + isShardedCluster := resourceType == ShardedClusterType && cluster.TypeName == "SHARDED_REPLICA_SET" + shouldUpdateBackupConfiguration := nameIsEqual && (isReplicaSet || isShardedCluster) + if !shouldUpdateBackupConfiguration { + continue + } + + needToRequeue := desiredStatus != desiredConfig.Status + if needToRequeue { + result.Requeue() + } + + // If we are configuring a sharded cluster, we must only update the config of the whole cluster, not each individual shard. + // Status: 409 (Conflict), ErrorCode: CANNOT_MODIFY_SHARD_BACKUP_CONFIG, Detail: Cannot modify backup configuration for individual shard; use cluster ID 611a63f668d22f4e2e62c2e3 for entire cluster. + // If backup was never enabled and the deployment has `spec.backup.mode=disabled` specified + // we don't send this state to OM, or we will get + // CANNOT_STOP_BACKUP_INVALID_STATE, Detail: Cannot stop backup unless the cluster is in the STARTED state.' + if desiredConfig.Status == Stopped && config.Status == Inactive { + continue + } + + // When mdb is newly created or backup is being enabled from terminated state, it is not possible to send snapshot schedule to OM, + // so the first run will be skipped. Update will be executed again at the end of this method when the backup reaches valid status. + // When the backup is already configured (not in INACTIVE state) and it is not changing its status, then this execution will update snapshot schedule. + // When both status and snapshot schedule is changing (e.g. backup starting from stopped), then this method will update snapshot schedule twice, which is harmless. + if err := updateSnapshotSchedule(mdb.GetBackupSpec().SnapshotSchedule, configReadUpdater, config, log); err != nil { + return workflow.Failed(err), nil + } + + if desiredConfig.Status == config.Status { + log.Debug("Config is already in the desired state, not updating configuration") + + // we are already in the desired state, nothing to change + // if we attempt to send the desired state again we get + // CANNOT_START_BACKUP_INVALID_STATE: Cannot start backup unless the cluster is in the INACTIVE or STOPPED state. + continue + } + + updatedConfig, err := configReadUpdater.UpdateBackupConfig(desiredConfig) + if err != nil { + return workflow.Failed(err), nil + } + + log.Debugw("Project Backup Configuration", "desiredConfig", desiredConfig, "updatedConfig", updatedConfig) + + if !waitUntilBackupReachesStatus(configReadUpdater, updatedConfig, desiredConfig.Status, log) { + statusOpts, err := getCurrentBackupStatusOption(configReadUpdater, config.ClusterId) + if err != nil { + return workflow.Failed(err), nil + } + return workflow.Pending("Backup configuration %s has not yet reached the desired status", updatedConfig.ClusterId).WithRetry(1), statusOpts + } + + log.Debugf("Backup has reached the desired state of %s", desiredConfig.Status) + + // second run for cases when backup was inactive (see comment above) + if err := updateSnapshotSchedule(mdb.GetBackupSpec().SnapshotSchedule, configReadUpdater, desiredConfig, log); err != nil { + return workflow.Failed(err), nil + } + + backupOpts, err := getCurrentBackupStatusOption(configReadUpdater, desiredConfig.ClusterId) + if err != nil { + return workflow.Failed(err), nil + } + return result, backupOpts + } + + return result, nil +} + +func updateSnapshotSchedule(specSnapshotSchedule *mdbv1.SnapshotSchedule, configReadUpdater ConfigHostReadUpdater, config *Config, log *zap.SugaredLogger) error { + if specSnapshotSchedule == nil { + return nil + } + + // in Inactive state we cannot update snapshot schedule in OM + if config.Status == Inactive { + log.Debugf("Skipping updating backup snapshot schedule due to Inactive status") + return nil + } + + omSnapshotSchedule, err := configReadUpdater.ReadSnapshotSchedule(config.ClusterId) + if err != nil { + return xerrors.Errorf("failed to read snapshot schedule: %w", err) + } + + snapshotSchedule := mergeExistingScheduleWithSpec(*omSnapshotSchedule, *specSnapshotSchedule) + + // we need to use DeepEqual in order to compare pointers' underlying values + if !reflect.DeepEqual(snapshotSchedule, *omSnapshotSchedule) { + if err := configReadUpdater.UpdateSnapshotSchedule(snapshotSchedule.ClusterID, &snapshotSchedule); err != nil { + return xerrors.Errorf("failed to update backup snapshot schedule for cluster %s: %w", config.ClusterId, err) + } + log.Debugf("Updated backup snapshot schedule with: %s", snapshotSchedule) + } else { + log.Infof("Backup snapshot schedule is up to date") + } + + return nil +} + +// getCurrentBackupStatusOption fetches the latest information from the backup config +// with the given cluster id and returns the relevant status Options. +func getCurrentBackupStatusOption(configReader ConfigReader, clusterId string) ([]status.Option, error) { + config, err := configReader.ReadBackupConfig(clusterId) + if err != nil { + return nil, err + } + return []status.Option{ + status.NewBackupStatusOption( + string(config.Status), + )}, nil +} + +// getMongoDBBackupConfig builds the backup configuration from the given MongoDB resource +func getMongoDBBackupConfig(backupSpec *mdbv1.Backup, projectId string) *Config { + mappings := getStatusMappings() + return &Config{ + // the encryptionEnabled field is also only used in old backup, 4.2 backup will copy all files whether or not they are encrypted + // the encryption happens at the mongod level and should be managed by the customer + EncryptionEnabled: false, + + // 4.2 backup does not yet support filtering namespaces, both excluded and included namespaces will be ignored + ExcludedNamespaces: []string{}, + // validation requires exactly one of these being set + // INVALID_FILTERLIST, Detail: Backup configuration cannot specify both included namespaces and excluded namespaces. + IncludedNamespaces: nil, + + // we map our more declarative API to the values required by backup + Status: mappings[string(backupSpec.Mode)], + + // with 4.2 backup we only need to support wired tiger + StorageEngineName: wiredTigerStorageEngine, + // syncSource is only required on pre-4.2 backup, the value is still validated however so we can just send primary + SyncSource: "PRIMARY", + ProjectId: projectId, + } +} + +// getStatusMappings returns a map which maps the fields exposed on the CRD +// to the fields expected by the Backup API +func getStatusMappings() map[string]Status { + return map[string]Status{ + "enabled": Started, + "disabled": Stopped, + "terminated": Terminating, + } +} + +// getDesiredStatus takes the desired config and the current config and returns the Status +// that the operator should try to configure for this reconciliation +func getDesiredStatus(desiredConfig, currentConfig *Config) Status { + if currentConfig == nil { + return desiredConfig.Status + } + // valid transitions can be found here https://github.com/10gen/mms/blob/7487cf31e775a38703ca6ef247b31b4d10c78c41/server/src/main/com/xgen/svc/mms/api/res/ApiBackupConfigsResource.java#L186 + // transitioning from Started to Terminating is not a valid transition + // we need to first go to Stopped. + if desiredConfig.Status == Terminating && currentConfig.Status == Started { + return Stopped + } + + // transitioning from Stopped to Terminating is not possible, it is only possible through + // Stopped -> Started -> Terminating + if desiredConfig.Status == Stopped && currentConfig.Status == Terminating { + return Started + } + return desiredConfig.Status +} diff --git a/controllers/om/backup/mongodbresource_backup_test.go b/controllers/om/backup/mongodbresource_backup_test.go new file mode 100644 index 000000000..3d7cd750c --- /dev/null +++ b/controllers/om/backup/mongodbresource_backup_test.go @@ -0,0 +1,49 @@ +package backup + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetDesiredStatus(t *testing.T) { + + t.Run("Test transition from enabled to disabled", func(t *testing.T) { + desired := Config{ + Status: Stopped, + } + current := Config{ + Status: Started, + } + assert.Equal(t, Stopped, getDesiredStatus(&desired, ¤t)) + }) + t.Run("Test transition from disabled to enabled", func(t *testing.T) { + desired := Config{ + Status: Started, + } + current := Config{ + Status: Stopped, + } + assert.Equal(t, Started, getDesiredStatus(&desired, ¤t)) + }) + t.Run("Test transition from enabled to terminated", func(t *testing.T) { + desired := Config{ + Status: Terminating, + } + current := Config{ + Status: Started, + } + assert.Equal(t, Stopped, getDesiredStatus(&desired, ¤t)) + }) + + t.Run("Test transition from terminated to disabled", func(t *testing.T) { + desired := Config{ + Status: Stopped, + } + current := Config{ + Status: Terminating, + } + assert.Equal(t, Started, getDesiredStatus(&desired, ¤t)) + }) + +} diff --git a/controllers/om/backup/s3config.go b/controllers/om/backup/s3config.go new file mode 100644 index 000000000..ff5aac715 --- /dev/null +++ b/controllers/om/backup/s3config.go @@ -0,0 +1,162 @@ +package backup + +import ( + "fmt" + + omv1 "github.com/10gen/ops-manager-kubernetes/api/v1/om" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/util/versionutil" + "github.com/blang/semver" +) + +type authmode string + +const ( + KEYS authmode = "KEYS" + IAM authmode = "IAM_ROLE" +) + +type S3ConfigResponse struct { + S3Configs []S3Config `json:"results"` +} + +// https://docs.opsmanager.mongodb.com/current/reference/api/admin/backup/snapshot/s3Configs/create-one-s3-blockstore-configuration/#request-body-parameters +type S3Config struct { + S3Bucket + S3Credentials + + // Flag indicating the style of this endpoint. + // true: Path-style URL endpoint eg. s3.amazonaws.com/ + // false: Virtual-host-style URL endpoint eg. .s3.amazonaws.com + PathStyleAccessEnabled bool `json:"pathStyleAccessEnabled"` + + // Flag indicating whether you can assign backup jobs to this data store. + AssignmentEnabled bool `json:"assignmentEnabled"` + + // Flag indicating whether or not you accepted the terms of service for using S3-compatible stores with Ops Manager. + // If this is false, the request results in an error and Ops Manager doesn’t create the S3-compatible store. + AcceptedTos bool `json:"acceptedTos"` + + // Unique name that labels this S3 Snapshot Store. + Id string `json:"id"` + + // Positive integer indicating the maximum number of connections to this S3 blockstore. + MaxConnections int `json:"s3MaxConnections"` + + // Comma-separated list of hosts in the format that can access this S3 blockstore. + Uri string `json:"uri"` + + // fields the operator will not configure. All of these can be changed via the UI and the operator + // will not reset their values on reconciliation + EncryptedCredentials bool `json:"encryptedCredentials"` + Labels []string `json:"labels"` + LoadFactor int `json:"loadFactor,omitempty"` + + AuthMethod string `json:"s3AuthMethod"` + + WriteConcern string `json:"writeConcern,omitempty"` + + // Region where the S3 bucket resides. + // This is currently set to the empty string to avoid HELP-22791. + S3RegionOverride *string `json:"s3RegionOverride,omitempty"` + + // Flag indicating whether this S3 blockstore enables server-side encryption. + SseEnabled bool `json:"sseEnabled"` + + // Required for OM 4.4 + DisableProxyS3 *bool `json:"disableProxyS3,omitempty"` + + // Flag indicating whether this S3 blockstore only accepts connections encrypted using TLS. + Ssl bool `json:"ssl"` + + // CustomCertificates is a list of valid Certificate Authority certificates that apply to the associated S3 bucket. + CustomCertificates []S3CustomCertificate `json:"customCertificates,omitempty"` +} + +// S3CustomCertificate stores the filename or contents of a custom certificate PEM file. +type S3CustomCertificate struct { + // Filename identifies the Certificate Authority PEM file. + Filename string `json:"filename"` + // CertString contains the contents of the Certificate Authority PEM file that comprise your Certificate Authority chain. + CertString string `json:"certString"` +} + +type S3Bucket struct { + // Name of the S3 bucket that hosts the S3 blockstore. + Name string `json:"s3BucketName"` + // URL used to access this AWS S3 or S3-compatible bucket. + Endpoint string `json:"s3BucketEndpoint"` +} + +type S3Credentials struct { + // AWS Access Key ID that can access the S3 bucket specified in s3BucketName. + AccessKey string `json:"awsAccessKey"` + + // AWS Secret Access Key that can access the S3 bucket specified in s3BucketName. + SecretKey string `json:"awsSecretKey"` +} + +func NewS3Config(opsManager omv1.MongoDBOpsManager, s3Config omv1.S3Config, uri string, s3CustomCertificate S3CustomCertificate, bucket S3Bucket, s3Creds *S3Credentials) S3Config { + authMode := IAM + cred := S3Credentials{} + + if s3Creds != nil { + authMode = KEYS + cred = *s3Creds + } + + config := S3Config{ + S3Bucket: bucket, + S3Credentials: cred, + AcceptedTos: true, + AssignmentEnabled: true, // default to enabled. This will not be overridden on merge so it can be manually disabled in UI. + SseEnabled: false, + DisableProxyS3: nil, + Id: s3Config.Name, + Uri: uri, + MaxConnections: util.DefaultS3MaxConnections, // can be configured in UI + Labels: s3Config.AssignmentLabels, + EncryptedCredentials: false, + PathStyleAccessEnabled: true, + AuthMethod: string(authMode), + S3RegionOverride: &s3Config.S3RegionOverride, + } + + version, err := versionutil.StringToSemverVersion(opsManager.Spec.Version) + if err == nil { + config.DisableProxyS3 = util.BooleanRef(false) + // Attributes that are only available in 5.0+ version of Ops Manager. + if s3Config.CustomCertificate && version.GTE(semver.MustParse("5.0.0")) { + // both filename and path need to be provided. + if s3CustomCertificate.CertString != "" && s3CustomCertificate.Filename != "" { + // CustomCertificates needs to be a pointer for it to not be + // passed as part of the API request. + config.CustomCertificates = append(config.CustomCertificates, s3CustomCertificate) + } + } + } + + return config +} + +func (s S3Config) Identifier() interface{} { + return s.Id +} + +// MergeIntoOpsManagerConfig performs the merge operation of the Operator config view ('s') into the OM owned one +// ('opsManagerS3Config') +func (s S3Config) MergeIntoOpsManagerConfig(opsManagerS3Config S3Config) S3Config { + opsManagerS3Config.Id = s.Id + opsManagerS3Config.S3Credentials = s.S3Credentials + opsManagerS3Config.S3Bucket = s.S3Bucket + opsManagerS3Config.PathStyleAccessEnabled = s.PathStyleAccessEnabled + opsManagerS3Config.Uri = s.Uri + opsManagerS3Config.S3RegionOverride = s.S3RegionOverride + opsManagerS3Config.Labels = s.Labels + return opsManagerS3Config +} + +func (s S3Config) String() string { + return fmt.Sprintf("id %s, uri: %s, enabled: %t, awsAccessKey: %s, awsSecretKey: %s, bucketEndpoint: %s, bucketName: %s, pathStyleAccessEnabled: %t", + s.Id, util.RedactMongoURI(s.Uri), s.AssignmentEnabled, util.Redact(s.AccessKey), util.Redact(s.SecretKey), s.S3Bucket.Endpoint, s.S3Bucket.Name, s.PathStyleAccessEnabled) +} diff --git a/controllers/om/backup/snapshot_schedule.go b/controllers/om/backup/snapshot_schedule.go new file mode 100644 index 000000000..df770ea14 --- /dev/null +++ b/controllers/om/backup/snapshot_schedule.go @@ -0,0 +1,79 @@ +package backup + +import ( + "fmt" + "strings" + + "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" +) + +// SnapshotSchedule object represents request and response body object of get and update snapshot schedule request +// https://www.mongodb.com/docs/ops-manager/master/reference/api/backup/update-one-snapshot-schedule-by-cluster-id/#request-body-parameters +type SnapshotSchedule struct { + GroupID string `json:"groupId,omitempty"` + ClusterID string `json:"clusterId,omitempty"` + ClusterCheckpointIntervalMin *int `json:"clusterCheckpointIntervalMin,omitempty"` + DailySnapshotRetentionDays *int `json:"dailySnapshotRetentionDays,omitempty"` + FullIncrementalDayOfWeek *string `json:"fullIncrementalDayOfWeek,omitempty"` + MonthlySnapshotRetentionMonths *int `json:"monthlySnapshotRetentionMonths,omitempty"` + PointInTimeWindowHours *int `json:"pointInTimeWindowHours,omitempty"` + ReferenceHourOfDay *int `json:"referenceHourOfDay,omitempty"` + ReferenceMinuteOfHour *int `json:"referenceMinuteOfHour,omitempty"` + SnapshotIntervalHours *int `json:"snapshotIntervalHours,omitempty"` + SnapshotRetentionDays *int `json:"snapshotRetentionDays,omitempty"` + WeeklySnapshotRetentionWeeks *int `json:"weeklySnapshotRetentionWeeks,omitempty"` + // ReferenceTimeZoneOffset is not handled deliberately, because OM converts ReferenceHourOfDay to UTC and saves always +0000 offset, + // and this would cause constant updates, due to always different timezone offset. + // ReferenceTimeZoneOffset *string `json:"referenceTimeZoneOffset,omitempty"` +} + +func ptrToStr[T any](val *T) string { + if val != nil { + return fmt.Sprintf("%v", *val) + } + return "nil" +} + +func (s SnapshotSchedule) String() string { + str := strings.Builder{} + _, _ = str.WriteString("GroupID: " + s.GroupID) + _, _ = str.WriteString(", ClusterID: " + s.ClusterID) + _, _ = str.WriteString(", ClusterCheckpointIntervalMin: " + ptrToStr(s.ClusterCheckpointIntervalMin)) + _, _ = str.WriteString(", DailySnapshotRetentionDays: " + ptrToStr(s.DailySnapshotRetentionDays)) + _, _ = str.WriteString(", FullIncrementalDayOfWeek: " + ptrToStr(s.FullIncrementalDayOfWeek)) + _, _ = str.WriteString(", MonthlySnapshotRetentionMonths: " + ptrToStr(s.MonthlySnapshotRetentionMonths)) + _, _ = str.WriteString(", PointInTimeWindowHours: " + ptrToStr(s.PointInTimeWindowHours)) + _, _ = str.WriteString(", ReferenceHourOfDay: " + ptrToStr(s.ReferenceHourOfDay)) + _, _ = str.WriteString(", ReferenceMinuteOfHour: " + ptrToStr(s.ReferenceMinuteOfHour)) + _, _ = str.WriteString(", SnapshotIntervalHours: " + ptrToStr(s.SnapshotIntervalHours)) + _, _ = str.WriteString(", SnapshotRetentionDays: " + ptrToStr(s.SnapshotRetentionDays)) + _, _ = str.WriteString(", WeeklySnapshotRetentionWeeks: " + ptrToStr(s.WeeklySnapshotRetentionWeeks)) + + return str.String() +} + +func mergeExistingScheduleWithSpec(existingSnapshotSchedule SnapshotSchedule, specSnapshotSchedule mdb.SnapshotSchedule) SnapshotSchedule { + snapshotSchedule := SnapshotSchedule{} + snapshotSchedule.ClusterID = existingSnapshotSchedule.ClusterID + snapshotSchedule.GroupID = existingSnapshotSchedule.GroupID + snapshotSchedule.ClusterCheckpointIntervalMin = pickFirstIfNotNil(specSnapshotSchedule.ClusterCheckpointIntervalMin, existingSnapshotSchedule.ClusterCheckpointIntervalMin) + snapshotSchedule.DailySnapshotRetentionDays = pickFirstIfNotNil(specSnapshotSchedule.DailySnapshotRetentionDays, existingSnapshotSchedule.DailySnapshotRetentionDays) + snapshotSchedule.FullIncrementalDayOfWeek = pickFirstIfNotNil(specSnapshotSchedule.FullIncrementalDayOfWeek, existingSnapshotSchedule.FullIncrementalDayOfWeek) + snapshotSchedule.MonthlySnapshotRetentionMonths = pickFirstIfNotNil(specSnapshotSchedule.MonthlySnapshotRetentionMonths, existingSnapshotSchedule.MonthlySnapshotRetentionMonths) + snapshotSchedule.PointInTimeWindowHours = pickFirstIfNotNil(specSnapshotSchedule.PointInTimeWindowHours, existingSnapshotSchedule.PointInTimeWindowHours) + snapshotSchedule.ReferenceHourOfDay = pickFirstIfNotNil(specSnapshotSchedule.ReferenceHourOfDay, existingSnapshotSchedule.ReferenceHourOfDay) + snapshotSchedule.ReferenceMinuteOfHour = pickFirstIfNotNil(specSnapshotSchedule.ReferenceMinuteOfHour, existingSnapshotSchedule.ReferenceMinuteOfHour) + snapshotSchedule.SnapshotIntervalHours = pickFirstIfNotNil(specSnapshotSchedule.SnapshotIntervalHours, existingSnapshotSchedule.SnapshotIntervalHours) + snapshotSchedule.SnapshotRetentionDays = pickFirstIfNotNil(specSnapshotSchedule.SnapshotRetentionDays, existingSnapshotSchedule.SnapshotRetentionDays) + snapshotSchedule.WeeklySnapshotRetentionWeeks = pickFirstIfNotNil(specSnapshotSchedule.WeeklySnapshotRetentionWeeks, existingSnapshotSchedule.WeeklySnapshotRetentionWeeks) + + return snapshotSchedule +} + +func pickFirstIfNotNil[T any](first *T, second *T) *T { + if first != nil { + return first + } else { + return second + } +} diff --git a/controllers/om/backup/snapshot_schedule_test.go b/controllers/om/backup/snapshot_schedule_test.go new file mode 100644 index 000000000..30507b6e0 --- /dev/null +++ b/controllers/om/backup/snapshot_schedule_test.go @@ -0,0 +1,65 @@ +package backup + +import ( + "testing" + + "k8s.io/utils/pointer" + + "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/stretchr/testify/assert" +) + +func TestMergeExistingScheduleWithSpec(t *testing.T) { + existingSchedule := SnapshotSchedule{ + GroupID: "a", + ClusterID: "b", + DailySnapshotRetentionDays: pointer.Int(2), + FullIncrementalDayOfWeek: pointer.String("c"), + MonthlySnapshotRetentionMonths: pointer.Int(3), + PointInTimeWindowHours: pointer.Int(4), + ReferenceHourOfDay: pointer.Int(5), + ReferenceMinuteOfHour: pointer.Int(6), + SnapshotIntervalHours: pointer.Int(8), + SnapshotRetentionDays: pointer.Int(9), + WeeklySnapshotRetentionWeeks: pointer.Int(10), + ClusterCheckpointIntervalMin: pointer.Int(11), + } + + specSchedule := mdb.SnapshotSchedule{ + SnapshotIntervalHours: pointer.Int(11), + SnapshotRetentionDays: pointer.Int(12), + DailySnapshotRetentionDays: pointer.Int(13), + WeeklySnapshotRetentionWeeks: pointer.Int(14), + MonthlySnapshotRetentionMonths: pointer.Int(15), + PointInTimeWindowHours: pointer.Int(16), + ReferenceHourOfDay: pointer.Int(17), + ReferenceMinuteOfHour: pointer.Int(18), + FullIncrementalDayOfWeek: pointer.String("cc"), + ClusterCheckpointIntervalMin: pointer.Int(11), + } + + merged := mergeExistingScheduleWithSpec(existingSchedule, specSchedule) + assert.Equal(t, specSchedule.SnapshotIntervalHours, merged.SnapshotIntervalHours) + assert.Equal(t, specSchedule.SnapshotRetentionDays, merged.SnapshotRetentionDays) + assert.Equal(t, specSchedule.DailySnapshotRetentionDays, merged.DailySnapshotRetentionDays) + assert.Equal(t, specSchedule.WeeklySnapshotRetentionWeeks, merged.WeeklySnapshotRetentionWeeks) + assert.Equal(t, specSchedule.MonthlySnapshotRetentionMonths, merged.MonthlySnapshotRetentionMonths) + assert.Equal(t, specSchedule.PointInTimeWindowHours, merged.PointInTimeWindowHours) + assert.Equal(t, specSchedule.ReferenceHourOfDay, merged.ReferenceHourOfDay) + assert.Equal(t, specSchedule.ReferenceMinuteOfHour, merged.ReferenceMinuteOfHour) + assert.Equal(t, specSchedule.FullIncrementalDayOfWeek, merged.FullIncrementalDayOfWeek) + assert.Equal(t, specSchedule.ClusterCheckpointIntervalMin, merged.ClusterCheckpointIntervalMin) + + emptySpecSchedule := mdb.SnapshotSchedule{} + merged = mergeExistingScheduleWithSpec(existingSchedule, emptySpecSchedule) + assert.Equal(t, existingSchedule.SnapshotIntervalHours, merged.SnapshotIntervalHours) + assert.Equal(t, existingSchedule.SnapshotRetentionDays, merged.SnapshotRetentionDays) + assert.Equal(t, existingSchedule.DailySnapshotRetentionDays, merged.DailySnapshotRetentionDays) + assert.Equal(t, existingSchedule.WeeklySnapshotRetentionWeeks, merged.WeeklySnapshotRetentionWeeks) + assert.Equal(t, existingSchedule.MonthlySnapshotRetentionMonths, merged.MonthlySnapshotRetentionMonths) + assert.Equal(t, existingSchedule.PointInTimeWindowHours, merged.PointInTimeWindowHours) + assert.Equal(t, existingSchedule.ReferenceHourOfDay, merged.ReferenceHourOfDay) + assert.Equal(t, existingSchedule.ReferenceMinuteOfHour, merged.ReferenceMinuteOfHour) + assert.Equal(t, existingSchedule.FullIncrementalDayOfWeek, merged.FullIncrementalDayOfWeek) + assert.Equal(t, existingSchedule.ClusterCheckpointIntervalMin, merged.ClusterCheckpointIntervalMin) +} diff --git a/controllers/om/backup_agent_config.go b/controllers/om/backup_agent_config.go new file mode 100644 index 000000000..e32f4e2ce --- /dev/null +++ b/controllers/om/backup_agent_config.go @@ -0,0 +1,95 @@ +package om + +import ( + "encoding/json" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" +) + +type BackupAgentTemplate struct { + Username string `json:"username"` + Password string `json:"password"` + SSLPemKeyFile string `json:"sslPEMKeyFile"` + LdapGroupDN string `json:"ldapGroupDN"` +} + +type BackupAgentConfig struct { + BackupAgentTemplate *BackupAgentTemplate + BackingMap map[string]interface{} +} + +func (bac *BackupAgentConfig) Apply() error { + merged, err := util.MergeWith(bac.BackupAgentTemplate, bac.BackingMap, &util.AutomationConfigTransformer{}) + if err != nil { + return err + } + bac.BackingMap = merged + return nil +} + +func (bac *BackupAgentConfig) SetAgentUserName(backupAgentSubject string) { + bac.BackupAgentTemplate.Username = backupAgentSubject +} + +func (bac *BackupAgentConfig) UnsetAgentUsername() { + bac.BackupAgentTemplate.Username = util.MergoDelete +} + +func (bac *BackupAgentConfig) SetAgentPassword(pwd string) { + bac.BackupAgentTemplate.Password = pwd +} + +func (bac *BackupAgentConfig) UnsetAgentPassword() { + bac.BackupAgentTemplate.Password = util.MergoDelete +} + +func (bac *BackupAgentConfig) EnableX509Authentication(backupAgentSubject string) { + bac.BackupAgentTemplate.SSLPemKeyFile = util.AutomationAgentPemFilePath + bac.SetAgentUserName(backupAgentSubject) +} + +func (bac *BackupAgentConfig) DisableX509Authentication() { + bac.BackupAgentTemplate.SSLPemKeyFile = util.MergoDelete + bac.UnsetAgentUsername() +} + +func (bac *BackupAgentConfig) EnableLdapAuthentication(backupAgentSubject string, backupAgentPwd string) { + bac.SetAgentUserName(backupAgentSubject) + bac.SetAgentPassword(backupAgentPwd) +} + +func (bac *BackupAgentConfig) DisableLdapAuthentication() { + bac.UnsetAgentUsername() + bac.UnsetAgentPassword() + bac.UnsetLdapGroupDN() +} + +func (bac *BackupAgentConfig) SetLdapGroupDN(ldapGroupDn string) { + bac.BackupAgentTemplate.LdapGroupDN = ldapGroupDn +} + +func (bac *BackupAgentConfig) UnsetLdapGroupDN() { + bac.BackupAgentTemplate.LdapGroupDN = util.MergoDelete +} + +// BuildBackupAgentConfigFromBytes +func BuildBackupAgentConfigFromBytes(jsonBytes []byte) (*BackupAgentConfig, error) { + fullMap := make(map[string]interface{}) + if err := json.Unmarshal(jsonBytes, &fullMap); err != nil { + return nil, err + } + + config := &BackupAgentConfig{BackingMap: fullMap} + template := &BackupAgentTemplate{} + if username, ok := fullMap["username"].(string); ok { + template.Username = username + } + + if sslPemKeyfile, ok := fullMap["sslPEMKeyFile"].(string); ok { + template.SSLPemKeyFile = sslPemKeyfile + } + + config.BackupAgentTemplate = template + + return config, nil +} diff --git a/controllers/om/backup_agent_test.go b/controllers/om/backup_agent_test.go new file mode 100644 index 000000000..4ceaba48c --- /dev/null +++ b/controllers/om/backup_agent_test.go @@ -0,0 +1,79 @@ +package om + +import ( + "testing" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/stretchr/testify/assert" +) + +var testBackupAgentConfig = *getTestBackupConfig() + +func getLinuxUrls(config BackupAgentConfig) map[string]interface{} { + return config.BackingMap["urls"].(map[string]interface{})["linux"].(map[string]interface{}) +} + +func getTestBackupConfig() *BackupAgentConfig { + bac, _ := BuildBackupAgentConfigFromBytes(loadBytesFromTestData("backup_config.json")) + return bac +} + +func TestFieldsAreUpdatedBackupConfig(t *testing.T) { + config := getTestBackupConfig() + config.BackupAgentTemplate.Username = "my-backup-user-name" + config.BackupAgentTemplate.SSLPemKeyFile = "my-backup-pem-file" + + config.Apply() + + assert.Equal(t, config.BackingMap["username"], "my-backup-user-name") + assert.Equal(t, config.BackingMap["sslPEMKeyFile"], "my-backup-pem-file") +} + +func TestBackupFieldsAreNotLost(t *testing.T) { + config := getTestBackupConfig() + config.EnableX509Authentication("namespace") + + assert.Contains(t, config.BackingMap, "logPath") + assert.Contains(t, config.BackingMap, "logRotate") + assert.Contains(t, config.BackingMap, "urls") + + config.Apply() + + assert.Equal(t, config.BackingMap["logPath"], testBackupAgentConfig.BackingMap["logPath"]) + assert.Equal(t, config.BackingMap["logRotate"], testBackupAgentConfig.BackingMap["logRotate"]) + assert.Equal(t, config.BackingMap["urls"], testBackupAgentConfig.BackingMap["urls"]) + +} + +func TestNestedFieldsAreNotLost(t *testing.T) { + config := getTestBackupConfig() + + config.EnableX509Authentication("namespace") + + config.Apply() + + urls := config.BackingMap["urls"].(map[string]interface{}) + + assert.Contains(t, urls, "linux") + assert.Contains(t, urls, "osx") + assert.Contains(t, urls, "windows") + + linuxUrls := urls["linux"].(map[string]interface{}) + + testUrls := getLinuxUrls(testBackupAgentConfig) + + assert.Equal(t, linuxUrls["default"], testUrls["default"]) + assert.Equal(t, linuxUrls["ppc64le_rhel7"], testUrls["ppc64le_rhel7"]) + assert.Equal(t, linuxUrls["ppc64le_ubuntu1604"], testUrls["ppc64le_ubuntu1604"]) +} + +func TestFieldsCanBeDeleted(t *testing.T) { + config := getTestBackupConfig() + + config.BackupAgentTemplate.SSLPemKeyFile = util.MergoDelete + + config.Apply() + + assert.Equal(t, config.BackingMap["username"], testBackupAgentConfig.BackingMap["username"]) + assert.NotContains(t, config.BackingMap, "sslPEMKeyFile") +} diff --git a/controllers/om/backup_test.go b/controllers/om/backup_test.go new file mode 100644 index 000000000..723b54a1d --- /dev/null +++ b/controllers/om/backup_test.go @@ -0,0 +1,36 @@ +package om + +import ( + "os" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/backup" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" + + "go.uber.org/zap" +) + +// TestBackupWaitsForTermination tests that 'StopBackupIfEnabled' procedure waits for backup statuses on each stage +// (STARTED -> STOPPED, STOPPED -> INACTIVE) +func TestBackupWaitsForTermination(t *testing.T) { + os.Setenv(util.BackupDisableWaitSecondsEnv, "1") + os.Setenv(util.BackupDisableWaitRetriesEnv, "3") + + connection := NewMockedOmConnection(NewDeployment()) + connection.EnableBackup("test", backup.ReplicaSetType, uuid.New().String()) + connection.UpdateBackupStatusFunc = func(clusterId string, status backup.Status) error { + go func() { + // adding slight delay for each update + time.Sleep(200 * time.Millisecond) + connection.doUpdateBackupStatus(clusterId, status) + }() + return nil + } + backup.StopBackupIfEnabled(connection, connection, "test", backup.ReplicaSetType, zap.S()) + + connection.CheckResourcesAndBackupDeleted(t, "test") +} diff --git a/controllers/om/deployment.go b/controllers/om/deployment.go new file mode 100644 index 000000000..482f694dd --- /dev/null +++ b/controllers/om/deployment.go @@ -0,0 +1,1217 @@ +package om + +import ( + "encoding/gob" + "encoding/json" + "errors" + "fmt" + "math" + "regexp" + + mdbcv1 "github.com/mongodb/mongodb-kubernetes-operator/api/v1" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/automationconfig" + "golang.org/x/xerrors" + + "github.com/10gen/ops-manager-kubernetes/pkg/tls" + "github.com/10gen/ops-manager-kubernetes/pkg/util/maputil" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" + "github.com/blang/semver" + "github.com/spf13/cast" + "go.uber.org/zap" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" +) + +type DeploymentType int + +const ( + // Note that the default version constants shouldn't need to be changed often + // as the AutomationAgent upgrades both other agents automatically + + // MonitoringAgentDefaultVersion + MonitoringAgentDefaultVersion = "11.12.0.7388-1" + + // BackupAgentDefaultVersion + BackupAgentDefaultVersion = "11.12.0.7388-1" + + // Default listen address for Prometheus + ListenAddress = "0.0.0.0" +) + +func init() { + // gob is used to implement a deep copy internally. If a data type is part of + // a deep copy performed using util.MapDeepCopy—which includes anything used + // as part of a "process" object embedded within a deployment—then it must be + // registered below as otherwise the operator will successfully compile and + // run but be completely broken. + // TODO should we move this to main.go? + gob.Register(map[string]interface{}{}) + gob.Register([]interface{}{}) + gob.Register(map[string]int{}) + gob.Register(map[string]string{}) + gob.Register([]interface{}{}) + gob.Register([]Process{}) + gob.Register([]ReplicaSet{}) + gob.Register([]ReplicaSetMember{}) + gob.Register([]ShardedCluster{}) + gob.Register([]MongoDbVersionConfig{}) + gob.Register([]Shard{}) + gob.Register(ProcessTypeMongos) + gob.Register(mdbv1.MongoDBHorizonConfig{}) + + gob.Register(tls.Require) + gob.Register(tls.Prefer) + gob.Register(tls.Allow) + gob.Register(tls.Disabled) + gob.Register([]mdbv1.MongoDbRole{}) + gob.Register([]automationconfig.MemberOptions{}) +} + +// Deployment is a map representing the automation agent's cluster configuration. +// For more information see the following documentation: +// https://docs.opsmanager.mongodb.com/current/reference/cluster-configuration/ +// https://github.com/10gen/mms-automation/blob/master/go_planner/config_specs/clusterConfig_spec.md +// +// Dev note: it's important to keep to the following principle during development: we don't use structs for json +// (de)serialization as we don't want to own the schema and synchronize it with the api one constantly. Also we don't +// want to override any configuration provided by OM by accident. The Operator only sets the configuration it "owns" but +// keeps the other one that was set by the user in Ops Manager if any +type Deployment map[string]interface{} + +// BuildDeploymentFromBytes +func BuildDeploymentFromBytes(jsonBytes []byte) (Deployment, error) { + deployment := Deployment{} + err := json.Unmarshal(jsonBytes, &deployment) + return deployment, err +} + +// NewDeployment +func NewDeployment() Deployment { + ans := Deployment{} + ans.setProcesses(make([]Process, 0)) + ans.setReplicaSets(make([]ReplicaSet, 0)) + ans.setShardedClusters(make([]ShardedCluster, 0)) + ans.setMonitoringVersions(make([]interface{}, 0)) + ans.setBackupVersions(make([]interface{}, 0)) + + // these keys are required to exist for mergo to merge + // correctly + ans["auth"] = make(map[string]interface{}) + ans["tls"] = map[string]interface{}{ + "clientCertificateMode": util.OptionalClientCertficates, + "CAFilePath": util.CAFilePathInContainer, + } + return ans +} + +// TLSConfigurationWillBeDisabled checks that if applying this security configuration the Deployment +// TLS configuration will go from Enabled -> Disabled. +func (d Deployment) TLSConfigurationWillBeDisabled(security *mdbv1.Security) bool { + tlsIsCurrentlyEnabled := false + + // To detect that TLS is enabled, it is sufficient to check for the + // d["tls"]["CAFilePath"] attribute to have a value different from nil. + if tls, ok := d["tls"]; ok { + if caFilePath, ok := tls.(map[string]interface{})["CAFilePath"]; ok { + if caFilePath != nil { + tlsIsCurrentlyEnabled = true + } + } + } + + return tlsIsCurrentlyEnabled && !security.IsTLSEnabled() +} + +// ConfigureTLS configures the deployment's TLS settings from the security +// specification provided by the user in the mongodb resource. +func (d Deployment) ConfigureTLS(security *mdbv1.Security, caFilePath string) { + if !security.IsTLSEnabled() { + d["tls"] = map[string]any{"clientCertificateMode": string(automationconfig.ClientCertificateModeOptional)} + return + } + + tlsConfig := util.ReadOrCreateMap(d, "tls") + // ClientCertificateMode detects if Ops Manager requires client certification - may be there will be no harm + // setting this to "REQUIRED" always (need to check). Otherwise, this should be configurable + // see OM configurations that affects this setting from AA side: + // https://docs.opsmanager.mongodb.com/current/reference/configuration/#mms.https.ClientCertificateMode + //sslConfig["ClientCertificateMode"] = "OPTIONAL" + //sslConfig["AutoPEMKeyFilePath"] = util.PEMKeyFilePathInContainer + + tlsConfig["CAFilePath"] = caFilePath +} + +// MergeStandalone merges "operator" standalone ('standaloneMongo') to "OM" deployment ('d'). If we found the process +// with the same name - update some fields there. Otherwise, add the new one +func (d Deployment) MergeStandalone(standaloneMongo Process, specArgs26, prevArgs26 map[string]interface{}, l *zap.SugaredLogger) { + if l == nil { + l = zap.S() + } + log := l.With("standalone", standaloneMongo) + + // merging process in case exists, otherwise adding it + for _, pr := range d.getProcesses() { + if pr.Name() == standaloneMongo.Name() { + pr.mergeFrom(standaloneMongo, specArgs26, prevArgs26) + log.Debug("Merged process into existing one") + return + } + } + d.addProcess(standaloneMongo) + log.Debug("Added process as current OM deployment didn't have it") +} + +// MergeReplicaSet merges the "operator" replica set and its members to the "OM" deployment ("d"). If "alien" RS members are +// removed after merge - corresponding processes are removed as well. +func (d Deployment) MergeReplicaSet(operatorRs ReplicaSetWithProcesses, specArgs26, prevArgs26 map[string]interface{}, l *zap.SugaredLogger) { + if l == nil { + l = zap.S() + } + log := l.With("replicaSet", operatorRs.Rs.Name()) + + r := d.getReplicaSetByName(operatorRs.Rs.Name()) + // If the new replica set is bigger than old one - we need to copy first member to positions of new members so that + // they were merged with operator replica sets on next step + // (in case OM made any changes to existing processes - these changes must be propagated to new members). + if r != nil && len(operatorRs.Rs.Members()) > len(r.Members()) { + if err := d.copyFirstProcessToNewPositions(operatorRs.Processes, len(r.Members()), l); err != nil { + // I guess this error is not so serious to fail the whole process - RS will be scaled up anyway + log.Error("Failed to copy first process (so new replica set processes may miss Ops Manager changes done to "+ + "existing replica set processes): %s", err) + } + } + + // Merging all RS processes + for _, p := range operatorRs.Processes { + d.MergeStandalone(p, specArgs26, prevArgs26, log) + } + + if r == nil { + // Adding a new Replicaset + d.addReplicaSet(operatorRs.Rs) + log.Debugw("Added replica set as current OM deployment didn't have it") + } else { + + processesToRemove := r.mergeFrom(operatorRs.Rs) + log.Debugw("Merged replica set into existing one") + + if len(processesToRemove) > 0 { + d.removeProcesses(processesToRemove, log) + log.Debugw("Removed processes as they were removed from replica set", "processesToRemove", processesToRemove) + } + } + + // In both cases (the new replicaset was added to OM deployment or it was merged with OM one) we need to make sure + // there are no more than 7 voting members + d.limitVotingMembers(operatorRs.Rs.Name()) +} + +// ConfigurePrometheus adds Prometheus configuration to `Deployment` resource. +// +// If basic auth is enabled, then `hash` and `salt` need to be calculated by caller and passed in. +func (d Deployment) ConfigurePrometheus(prom *mdbcv1.Prometheus, hash string, salt string, certName string) automationconfig.Prometheus { + if prom == nil { + // No prometheus configuration this time + return automationconfig.Prometheus{} + } + + promConfig := automationconfig.NewDefaultPrometheus(prom.Username) + + if prom.TLSSecretRef.Name != "" { + promConfig.TLSPemPath = util.SecretVolumeMountPathPrometheus + "/" + certName + promConfig.Scheme = "https" + } else { + promConfig.TLSPemPath = "" + promConfig.Scheme = "http" + } + + promConfig.PasswordHash = hash + promConfig.PasswordSalt = salt + + if prom.Port > 0 { + promConfig.ListenAddress = fmt.Sprintf("%s:%d", ListenAddress, prom.Port) + } + + if prom.MetricsPath != "" { + promConfig.MetricsPath = prom.MetricsPath + } + + d["prometheus"] = promConfig + + return promConfig +} + +// DeploymentShardedClusterMergeOptions contains all of the required values to update the ShardedCluster +// in the automation config. These values should be provided my the MongoDB resource. +type DeploymentShardedClusterMergeOptions struct { + Name string + MongosProcesses []Process + ConfigServerRs ReplicaSetWithProcesses + Shards []ReplicaSetWithProcesses + Finalizing bool + ConfigServerAdditionalOptionsDesired map[string]interface{} + MongosAdditionalOptionsDesired map[string]interface{} + ShardAdditionalOptionsDesired map[string]interface{} + ConfigServerAdditionalOptionsPrev map[string]interface{} + MongosAdditionalOptionsPrev map[string]interface{} + ShardAdditionalOptionsPrev map[string]interface{} +} + +// MergeShardedCluster merges "operator" sharded cluster into "OM" deployment ("d"). Mongos, config servers and all shards +// are all merged one by one. +// 'shardsToRemove' is an array containing names of shards which should be removed. +func (d Deployment) MergeShardedCluster(opts DeploymentShardedClusterMergeOptions) (bool, error) { + log := zap.S().With("sharded cluster", opts.Name) + + err := d.mergeMongosProcesses(opts, log) + if err != nil { + return false, err + } + + d.mergeConfigReplicaSet(opts, log) + + shardsScheduledForRemoval := d.mergeShards(opts, log) + + return shardsScheduledForRemoval, nil +} + +// AddMonitoringAndBackup adds monitoring and backup agents to each process +// The automation agent will update the agents versions to the latest version automatically +// Note, that these two are deliberately combined together as all clients (standalone, rs etc) need both backup and monitoring +// together +func (d Deployment) AddMonitoringAndBackup(log *zap.SugaredLogger, tls bool, caFilepath string) { + if len(d.getProcesses()) == 0 { + return + } + d.AddMonitoring(log, tls, caFilepath) + d.addBackup(log) +} + +func (d Deployment) ReplicaSets() []ReplicaSet { + return d["replicaSets"].([]ReplicaSet) +} + +// AddMonitoring adds monitoring agents for all processes in the deployment +func (d Deployment) AddMonitoring(log *zap.SugaredLogger, tls bool, caFilePath string) { + if len(d.getProcesses()) == 0 { + return + } + monitoringVersions := d.getMonitoringVersions() + for _, p := range d.getProcesses() { + found := false + var monitoringVersion map[string]interface{} + for _, m := range monitoringVersions { + monitoringVersion = m.(map[string]interface{}) + if monitoringVersion["hostname"] == p.HostName() { + found = true + break + } + } + + if !found { + monitoringVersion = map[string]interface{}{ + "hostname": p.HostName(), + "name": MonitoringAgentDefaultVersion, + } + log.Debugw("Added monitoring agent configuration", "host", p.HostName(), "tls", tls) + monitoringVersions = append(monitoringVersions, monitoringVersion) + } + + monitoringVersion["hostname"] = p.HostName() + + if tls { + additionalParams := map[string]string{ + "useSslForAllConnections": "true", + "sslTrustedServerCertificates": caFilePath, + } + + pemKeyFile := p.EnsureTLSConfig()["PEMKeyFile"] + if pemKeyFile != nil { + additionalParams["sslClientCertificate"] = pemKeyFile.(string) + } + + monitoringVersion["additionalParams"] = additionalParams + } + } + d.setMonitoringVersions(monitoringVersions) +} + +// RemoveMonitoringAndBackup removes both monitoring and backup agent configurations. This must be called when the +// Mongodb resource is being removed, otherwise UI will show non-existing agents in the "servers" tab +func (d Deployment) RemoveMonitoringAndBackup(names []string, log *zap.SugaredLogger) { + d.removeMonitoring(names) + d.removeBackup(names, log) +} + +// DisableProcesses +func (d Deployment) DisableProcesses(processNames []string) { + for _, p := range processNames { + d.getProcessByName(p).SetDisabled(true) + } +} + +// MarkRsMembersUnvoted +func (d Deployment) MarkRsMembersUnvoted(rsName string, rsMembers []string) error { + rs := d.getReplicaSetByName(rsName) + if rs == nil { + return errors.New("Failed to find Replica Set " + rsName) + } + + failedMembers := "" + for _, m := range rsMembers { + rsMember := rs.findMemberByName(m) + if rsMember == nil { + failedMembers += m + } else { + rsMember.setVotes(0).setPriority(0) + } + } + if failedMembers != "" { + return xerrors.Errorf("failed to find the following members of Replica Set %s: %v", rsName, failedMembers) + } + return nil +} + +// RemoveProcessByName removes the process from deployment +// Note, that the backup and monitoring configs are also cleaned up +func (d Deployment) RemoveProcessByName(name string, log *zap.SugaredLogger) error { + s := d.getProcessByName(name) + if s == nil { + return xerrors.Errorf("Standalone %s does not exist", name) + } + + d.removeProcesses([]string{s.Name()}, log) + + return nil +} + +// RemoveReplicaSetByName removes replica set and all relevant processes from deployment +// Note, that the backup and monitoring configs are also cleaned up +func (d Deployment) RemoveReplicaSetByName(name string, log *zap.SugaredLogger) error { + rs := d.getReplicaSetByName(name) + if rs == nil { + return errors.New("ReplicaSet does not exist") + } + + currentRs := d.getReplicaSets() + toKeep := make([]ReplicaSet, len(currentRs)-1) + i := 0 + for _, el := range currentRs { + if el.Name() != name { + toKeep[i] = el + i++ + } + } + + d.setReplicaSets(toKeep) + + members := rs.Members() + processNames := make([]string, len(members)) + for i, el := range members { + processNames[i] = el.Name() + } + d.removeProcesses(processNames, log) + + return nil +} + +// RemoveShardedClusterByName removes the sharded cluster element, all relevant replica sets and all processes. +// Note, that the backup and monitoring configs are also cleaned up +func (d Deployment) RemoveShardedClusterByName(clusterName string, log *zap.SugaredLogger) error { + sc := d.getShardedClusterByName(clusterName) + if sc == nil { + return errors.New("sharded Cluster does not exist") + } + + // 1. Remove the sharded cluster + toKeep := make([]ShardedCluster, 0) + for _, el := range d.getShardedClusters() { + if el.Name() != clusterName { + toKeep = append(toKeep, el) + } + } + + d.setShardedClusters(toKeep) + + // 2. Remove all replicasets and their processes for shards + shards := sc.shards() + shardNames := make([]string, len(shards)) + for _, el := range shards { + shardNames = append(shardNames, el.id()) + } + d.removeReplicaSets(shardNames, log) + + // 3. Remove config server replicaset + d.RemoveReplicaSetByName(sc.ConfigServerRsName(), log) + + // 4. Remove mongos processes for cluster + d.removeProcesses(d.getMongosProcessesNames(clusterName), log) + + return nil +} + +// returns an array of all the process names relevant to the given deployment +// these processes are the only ones checked for goal state when updating the +// deployment +func (d Deployment) GetProcessNames(kind interface{}, name string) []string { + switch kind.(type) { + case ShardedCluster: + return d.getShardedClusterProcessNames(name) + case ReplicaSet: + return d.getReplicaSetProcessNames(name) + case Standalone: + return []string{name} + default: + panic(xerrors.Errorf("unexpected kind: %v", kind)) + } +} + +// ConfigureInternalClusterAuthentication configures all processes in processNames to have the corresponding +// clusterAuthenticationMode enabled +func (d Deployment) ConfigureInternalClusterAuthentication(processNames []string, clusterAuthenticationMode string, internalClusterPath string) { + for _, p := range processNames { + if process := d.getProcessByName(p); process != nil { + process.ConfigureClusterAuthMode(clusterAuthenticationMode, internalClusterPath) + } + } +} + +// GetInternalClusterFilePath returns the first InternalClusterFilepath for the given list of processes. +func (d Deployment) GetInternalClusterFilePath(processNames []string) string { + for _, p := range processNames { + if process := d.getProcessByName(p); process != nil { + if !process.IsTLSEnabled() { + return "" + } + tlsConf := process.EnsureTLSConfig() + if v, ok := tlsConf["clusterFile"]; ok { + return v.(string) + } + } + } + return "" +} + +// SetInternalClusterFilePathOnlyIfItThePathHasChanged sets the internal cluster path for the given process names only if it has changed and has been set before. +func (d Deployment) SetInternalClusterFilePathOnlyIfItThePathHasChanged(names []string, filePath string, clusterAuth string) { + if currPath := d.GetInternalClusterFilePath(names); currPath != filePath && currPath != "" { + d.ConfigureInternalClusterAuthentication(names, clusterAuth, filePath) + } +} + +// MinimumMajorVersion returns the lowest major version in the entire deployment. +// this includes feature compatibility version. This can be used to determine +// which version of SCRAM-SHA the deployment can enable. +func (d Deployment) MinimumMajorVersion() uint64 { + if len(d.getProcesses()) == 0 { + return 0 + } + minimumMajorVersion := semver.Version{Major: math.MaxUint64} + for _, p := range d.getProcesses() { + if p.FeatureCompatibilityVersion() != "" { + fcv := fmt.Sprintf("%s.0", util.StripEnt(p.FeatureCompatibilityVersion())) + semverFcv, _ := semver.Make(fcv) + if semverFcv.LE(minimumMajorVersion) { + minimumMajorVersion = semverFcv + } + } else { + semverVersion, _ := semver.Make(util.StripEnt(p.Version())) + if semverVersion.LE(minimumMajorVersion) { + minimumMajorVersion = semverVersion + } + } + } + + return minimumMajorVersion.Major +} + +// allProcessesAreTLSEnabled ensures that every process in the given deployment is TLS enabled +// it is not possible to enable x509 authentication at the project level if a single process +// does not have TLS enabled. +func (d Deployment) AllProcessesAreTLSEnabled() bool { + for _, p := range d.getProcesses() { + if !p.IsTLSEnabled() { + return false + } + } + return true +} + +func (d Deployment) GetAllHostnames() []string { + hostnames := make([]string, d.NumberOfProcesses()) + for idx, p := range d.getProcesses() { + hostnames[idx] = p.Name() + } + + return hostnames +} + +func (d Deployment) NumberOfProcesses() int { + return len(d.getProcesses()) +} + +// anyProcessHasInternalClusterAuthentication determines if at least one process +// has internal cluster authentication enabled. If this is true, it is impossible to disable +// x509 authentication +func (d Deployment) AnyProcessHasInternalClusterAuthentication() bool { + return d.processesHaveInternalClusterAuthentication(d.getProcesses()) +} + +func (d Deployment) ExistingProcessesHaveInternalClusterAuthentication(processes []Process) bool { + deploymentProcesses := make([]Process, 0) + for _, p := range processes { + deploymentProcess := d.getProcessByName(p.Name()) + if deploymentProcess != nil { + deploymentProcesses = append(deploymentProcesses, *deploymentProcess) + } + } + return d.processesHaveInternalClusterAuthentication(deploymentProcesses) +} + +func (d Deployment) Serialize() ([]byte, error) { + return json.Marshal(d) +} + +// ToCanonicalForm performs serialization/deserialization to get a map without struct members +// This may be useful if the Operator version of Deployment (which may contain structs) needs to be compared with +// a deployment deserialized from json +func (d Deployment) ToCanonicalForm() Deployment { + bytes, err := d.Serialize() + if err != nil { + // dev error + panic(err) + } + var canonical Deployment + canonical, err = BuildDeploymentFromBytes(bytes) + if err != nil { + panic(err) + } + return canonical +} + +func (d Deployment) Version() int64 { + if _, ok := d["version"]; !ok { + return -1 + } + return cast.ToInt64(d["version"]) +} + +// ProcessBelongsToResource determines if `processName` belongs to `resourceName`. +func (d Deployment) ProcessBelongsToResource(processName, resourceName string) bool { + if stringutil.Contains(d.GetProcessNames(ShardedCluster{}, resourceName), processName) { + return true + } + if stringutil.Contains(d.GetProcessNames(ReplicaSet{}, resourceName), processName) { + return true + } + if stringutil.Contains(d.GetProcessNames(Standalone{}, resourceName), processName) { + return true + } + + return false +} + +// GetNumberOfExcessProcesses calculates how many processes do not belong to +// this resource. +func (d Deployment) GetNumberOfExcessProcesses(resourceName string) int { + processNames := d.GetAllProcessNames() + excessProcesses := len(processNames) + for _, p := range processNames { + if d.ProcessBelongsToResource(p, resourceName) { + excessProcesses -= 1 + } + } + // Edge case: for sharded cluster it's ok to have junk replica sets during scale down - we consider them as + // belonging to sharded cluster + if d.getShardedClusterByName(resourceName) != nil { + for _, r := range d.findReplicaSetsRemovedFromShardedCluster(resourceName) { + excessProcesses -= len(d.GetProcessNames(ReplicaSet{}, r)) + } + } + + return excessProcesses +} + +func (d Deployment) SetRoles(roles []mdbv1.MongoDbRole) { + d["roles"] = roles +} + +func (d Deployment) GetRoles() []mdbv1.MongoDbRole { + val, ok := d["roles"].([]mdbv1.MongoDbRole) + if !ok { + return []mdbv1.MongoDbRole{} + } + return val +} + +// GetAgentVersion returns the current version of all Agents in the deployment. It's empty until the +// 'automationConfig/updateAgentVersions' endpoint is called the first time +func (d Deployment) GetAgentVersion() string { + agentVersionMap := util.ReadOrCreateMap(d, "agentVersion") + return maputil.ReadMapValueAsString(agentVersionMap, "name") +} + +// Debug +func (d Deployment) Debug(l *zap.SugaredLogger) { + dep := Deployment{} + for key, value := range d { + if key != "mongoDbVersions" { + dep[key] = value + } + } + b, err := json.MarshalIndent(dep, "", " ") + if err != nil { + fmt.Println("error:", err) + } + l.Debugf(">> Deployment: \n %s \n", string(b)) +} + +// ProcessesCopy returns the COPY of processes in the deployment. +func (d Deployment) ProcessesCopy() []Process { + return d.deepCopy().getProcesses() +} + +// ReplicaSetsCopy returns the COPY of replicasets in the deployment. +func (d Deployment) ReplicaSetsCopy() []ReplicaSet { + return d.deepCopy().getReplicaSets() +} + +// ShardedClustersCopy returns the COPY of sharded clusters in the deployment. +func (d Deployment) ShardedClustersCopy() []ShardedCluster { + return d.deepCopy().getShardedClusters() +} + +// MonitoringVersionsCopy returns the COPY of monitoring versions in the deployment. +func (d Deployment) MonitoringVersionsCopy() []interface{} { + return d.deepCopy().getMonitoringVersions() +} + +// BackupVersionsCopy returns the COPY of backup versions in the deployment. +func (d Deployment) BackupVersionsCopy() []interface{} { + return d.deepCopy().getBackupVersions() +} + +// ***************************************** Private methods *********************************************************** + +func (d Deployment) getReplicaSetProcessNames(name string) []string { + processNames := make([]string, 0) + if rs := d.getReplicaSetByName(name); rs != nil { + for _, member := range rs.Members() { + processNames = append(processNames, member.Name()) + } + } + return processNames +} + +// GetShardedClusterShardProcessNames returns the process names for sharded cluster named "name" of index "shardNum". +func (d Deployment) GetShardedClusterShardProcessNames(name string, shardNum int) []string { + if sc := d.getShardedClusterByName(name); sc != nil { + if shardNum < 0 || shardNum >= len(sc.shards()) { + return nil + } + return d.getReplicaSetProcessNames(sc.shards()[shardNum].rs()) + } + return nil +} + +// getShardedClusterShardsProcessNames returns the process names fo sharded cluster named "name". +func (d Deployment) getShardedClusterShardsProcessNames(name string) []string { + processNames := make([]string, 0) + if sc := d.getShardedClusterByName(name); sc != nil { + for i := range sc.shards() { + processNames = append(processNames, d.GetShardedClusterShardProcessNames(name, i)...) + } + } + return processNames +} + +// GetShardedClusterConfigProcessNames returns the process names of config servers of the sharded cluster named "name" +func (d Deployment) GetShardedClusterConfigProcessNames(name string) []string { + if sc := d.getShardedClusterByName(name); sc != nil { + return d.getReplicaSetProcessNames(sc.ConfigServerRsName()) + } + return nil +} + +// GetShardedClusterMongosProcessNames returns the process names of mongoso the sharded cluster named "name" +func (d Deployment) GetShardedClusterMongosProcessNames(name string) []string { + if sc := d.getShardedClusterByName(name); sc != nil { + return d.getMongosProcessesNames(name) + } + return nil +} + +func (d Deployment) getShardedClusterProcessNames(name string) []string { + processNames := d.getShardedClusterShardsProcessNames(name) + processNames = append(processNames, d.GetShardedClusterConfigProcessNames(name)...) + processNames = append(processNames, d.GetShardedClusterMongosProcessNames(name)...) + return processNames +} + +func (d Deployment) mergeMongosProcesses(opts DeploymentShardedClusterMergeOptions, log *zap.SugaredLogger) error { + // First removing old mongos processes + for _, p := range d.getMongosProcessesNames(opts.Name) { + found := false + for _, v := range opts.MongosProcesses { + if p == v.Name() { + found = true + break + } + } + if !found { + d.removeProcesses([]string{p}, log) + log.Debugw("Removed redundant mongos process", "name", p) + } + } + // Making sure changes to existing mongos processes are propagated to new ones + if cntMongosProcesses := len(d.getMongosProcessesNames(opts.Name)); cntMongosProcesses > 0 && cntMongosProcesses < len(opts.MongosProcesses) { + if err := d.copyFirstProcessToNewPositions(opts.MongosProcesses, cntMongosProcesses, log); err != nil { + // I guess this error is not so serious to fail the whole process - mongoses will be scaled up anyway + log.Error("Failed to copy first mongos process (so new mongos processes may miss Ops Manager changes done to "+ + "existing mongos processes): %w", err) + } + } + + // Then merging mongos processes with existing ones + for _, p := range opts.MongosProcesses { + if p.ProcessType() != ProcessTypeMongos { + return errors.New(`all mongos processes must have processType="mongos"`) + } + p.setCluster(opts.Name) + d.MergeStandalone(p, opts.MongosAdditionalOptionsDesired, opts.MongosAdditionalOptionsPrev, log) + } + return nil +} + +func (d Deployment) getMongosProcessesNames(clusterName string) []string { + processNames := make([]string, 0) + for _, p := range d.getProcesses() { + if p.ProcessType() == ProcessTypeMongos && p.cluster() == clusterName { + processNames = append(processNames, p.Name()) + } + } + return processNames +} + +func (d Deployment) mergeConfigReplicaSet(opts DeploymentShardedClusterMergeOptions, l *zap.SugaredLogger) { + for _, p := range opts.ConfigServerRs.Processes { + p.setClusterRoleConfigSrv() + } + + d.MergeReplicaSet(opts.ConfigServerRs, opts.ConfigServerAdditionalOptionsDesired, opts.ConfigServerAdditionalOptionsPrev, l) +} + +// mergeShards does merge of replicasets for shards (which in turn merge each process) and merge or add the sharded cluster +// element as well +func (d Deployment) mergeShards(opts DeploymentShardedClusterMergeOptions, log *zap.SugaredLogger) bool { + // First merging the individual replica sets for each shard + for _, v := range opts.Shards { + d.MergeReplicaSet(v, opts.ShardAdditionalOptionsDesired, opts.ShardAdditionalOptionsPrev, log) + } + cluster := NewShardedCluster(opts.Name, opts.ConfigServerRs.Rs.Name(), opts.Shards) + + // Merging "sharding" json value + for _, s := range d.getShardedClusters() { + if s.Name() == opts.Name { + s.mergeFrom(cluster) + log.Debug("Merged sharded cluster into existing one") + + return d.handleShardsRemoval(opts.Finalizing, s, log) + } + } + // Adding the new sharded cluster + d.addShardedCluster(cluster) + log.Debug("Added sharded cluster as current OM deployment didn't have it") + return false +} + +// handleShardsRemoval is a complicated method handling different scenarios. +// - 'draining' array is empty and no extra shards were found in OM which should be removed - return +// - if 'finalizing' == false - this means that this is the 1st phase of the process - when the shards are due to be removed +// or have already been removed and their replica sets are added/already sit in the 'draining' array. Note, that this +// method can be called many times while in the 1st phase and 'draining' array is not empty - this means that the agent +// is performing the shards rebalancing +// - if 'finalizing' == true - this means that this is the 2nd phase of the process - when the shards were removed +// from the sharded cluster and their data was rebalanced to the rest of the shards. Now we can remove the replica sets +// and their processes and clean the 'draining' array. +func (d Deployment) handleShardsRemoval(finalizing bool, s ShardedCluster, log *zap.SugaredLogger) bool { + junkReplicaSets := d.findReplicaSetsRemovedFromShardedCluster(s.Name()) + + if len(junkReplicaSets) == 0 { + return false + } + + if !finalizing { + if len(junkReplicaSets) > 0 { + s.addToDraining(junkReplicaSets) + } + log.Infof("The following shards are scheduled for removal: %s", s.draining()) + return true + } else if len(junkReplicaSets) > 0 { + // Cleaning replica sets which used to be shards in past iterations. + s.removeDraining() + d.removeReplicaSets(junkReplicaSets, log) + log.Debugw("Removed replica sets as they were removed from sharded cluster", "replica sets", junkReplicaSets) + } + return false +} + +// GetAllProcessNames returns a list of names of processes in this deployment. This is, the names of all processes +// in the `processes` attribute of the deployment object. +func (d Deployment) GetAllProcessNames() (names []string) { + for _, p := range d.getProcesses() { + names = append(names, p.Name()) + } + return +} + +func (d Deployment) getProcesses() []Process { + if _, ok := d["processes"]; !ok { + return []Process{} + } + switch v := d["processes"].(type) { + case []Process: + return v + case []interface{}: + // seems we cannot directly cast the array of interfaces to array of Processes - have to manually copy references + ans := make([]Process, len(v)) + for i, val := range v { + ans[i] = NewProcessFromInterface(val) + } + return ans + default: + panic(fmt.Sprintf("Unexpected type of processes variable: %T", v)) + } +} + +func (d Deployment) getProcessesHostNames(names []string) []string { + ans := make([]string, len(names)) + + for i, n := range names { + if p := d.getProcessByName(n); p != nil { + ans[i] = p.HostName() + } + } + return ans +} + +func (d Deployment) setProcesses(processes []Process) { + d["processes"] = processes +} + +func (d Deployment) addProcess(p Process) { + d.setProcesses(append(d.getProcesses(), p)) +} + +func (d Deployment) removeProcesses(processNames []string, log *zap.SugaredLogger) { + // (CLOUDP-37709) implementation ideas: we remove agents for the processes if they are removed. Note, that + // processes removal happens also during merge operations - so hypothetically if OM added some processes that were + // removed by the Operator on merge - the agents will be removed from config as well. Seems this is quite safe and + // in the Operator-managed environment we'll never get the situation when some agents reside on the hosts which are + // not some processes. + d.RemoveMonitoringAndBackup(processNames, log) + + processes := make([]Process, 0) + + for _, p := range d.getProcesses() { + found := false + for _, p2 := range processNames { + if p.Name() == p2 { + found = true + } + } + if !found { + processes = append(processes, p) + } + } + + d.setProcesses(processes) + +} + +func (d Deployment) removeReplicaSets(replicaSets []string, log *zap.SugaredLogger) { + for _, v := range replicaSets { + d.RemoveReplicaSetByName(v, log) + } +} + +func (d Deployment) getProcessByName(name string) *Process { + for _, p := range d.getProcesses() { + if p.Name() == name { + return &p + } + } + + return nil +} + +func (d Deployment) getReplicaSetByName(name string) *ReplicaSet { + for _, r := range d.getReplicaSets() { + if r.Name() == name { + return &r + } + } + + return nil +} + +func (d Deployment) getShardedClusterByName(name string) *ShardedCluster { + for _, s := range d.getShardedClusters() { + if s.Name() == name { + return &s + } + } + + return nil +} + +func (d Deployment) getReplicaSets() []ReplicaSet { + switch v := d["replicaSets"].(type) { + case []ReplicaSet: + return v + case []interface{}: + ans := make([]ReplicaSet, len(v)) + for i, val := range v { + ans[i] = NewReplicaSetFromInterface(val) + } + return ans + default: + panic(fmt.Sprintf("Unexpected type of replicasets variable: %T", v)) + } +} + +func (d Deployment) setReplicaSets(replicaSets []ReplicaSet) { + d["replicaSets"] = replicaSets +} + +func (d Deployment) addReplicaSet(rs ReplicaSet) { + d.setReplicaSets(append(d.getReplicaSets(), rs)) +} + +func (d Deployment) getShardedClusters() []ShardedCluster { + switch v := d["sharding"].(type) { + case []ShardedCluster: + return v + case []interface{}: + ans := make([]ShardedCluster, len(v)) + for i, val := range v { + ans[i] = NewShardedClusterFromInterface(val) + } + return ans + default: + panic(fmt.Sprintf("Unexpected type of sharding variable: %T", v)) + } +} + +func (d Deployment) setShardedClusters(shardedClusters []ShardedCluster) { + d["sharding"] = shardedClusters +} + +func (d Deployment) addShardedCluster(shardedCluster ShardedCluster) { + d.setShardedClusters(append(d.getShardedClusters(), shardedCluster)) +} + +func (d Deployment) getMonitoringVersions() []interface{} { + return d["monitoringVersions"].([]interface{}) +} + +func (d Deployment) getBackupVersions() []interface{} { + return d["backupVersions"].([]interface{}) +} + +func (d Deployment) setMonitoringVersions(monitoring []interface{}) { + d["monitoringVersions"] = monitoring +} + +func (d Deployment) setBackupVersions(backup []interface{}) { + d["backupVersions"] = backup +} + +func (d Deployment) getTLS() map[string]interface{} { + return util.ReadOrCreateMap(d, "tls") +} + +// findReplicaSetsRemovedFromShardedCluster finds all replica sets which look like shards that have been removed from +// the sharded cluster. +// To make this method work correctly the shards MUST have the same prefix as a shard (which is true for the +// Operator-created resource) +func (d Deployment) findReplicaSetsRemovedFromShardedCluster(clusterName string) []string { + shardedCluster := d.getShardedClusterByName(clusterName) + clusterReplicaSets := shardedCluster.getAllReplicaSets() + ans := []string{} + + for _, v := range d.getReplicaSets() { + if !stringutil.Contains(clusterReplicaSets, v.Name()) && isShardOfShardedCluster(clusterName, v.Name()) { + ans = append(ans, v.Name()) + } + } + return ans +} + +func isShardOfShardedCluster(clusterName, rsName string) bool { + return regexp.MustCompile(`^` + clusterName + `-[0-9]+$`).MatchString(rsName) +} + +// removeMonitoring removes the monitoring agent configuration that match any of processes hosts 'processNames' parameter +// Note, that by contract there will be only one monitoring agent, but the method tries to be maximum safe and clean +// all matches (may be someone "hacked" the automation config manually and added the monitoring agents there) +// Note 2: it's ok if nothing was removed as the processes in the array may be from replica set from sharded cluster +// which doesn't have a monitoring agents (one monitoring agent per cluster) +func (d Deployment) removeMonitoring(processNames []string) { + monitoringVersions := d.getMonitoringVersions() + updatedMonitoringVersions := make([]interface{}, 0) + hostNames := d.getProcessesHostNames(processNames) + for _, m := range monitoringVersions { + monitoring := m.(map[string]interface{}) + hostname := monitoring["hostname"].(string) + if !stringutil.Contains(hostNames, hostname) { + updatedMonitoringVersions = append(updatedMonitoringVersions, m) + } else { + hostNames = stringutil.Remove(hostNames, hostname) + } + } + + d.setMonitoringVersions(updatedMonitoringVersions) +} + +// addBackup adds backup agent configuration for each of the processes of deployment +func (d Deployment) addBackup(log *zap.SugaredLogger) { + backupVersions := d.getBackupVersions() + for _, p := range d.getProcesses() { + found := false + for _, b := range backupVersions { + backup := b.(map[string]interface{}) + if backup["hostname"] == p.HostName() { + found = true + break + } + } + if !found { + backupVersions = append(backupVersions, + map[string]interface{}{"hostname": p.HostName(), "name": BackupAgentDefaultVersion}) + + log.Debugw("Added backup agent configuration", "host", p.HostName()) + } + } + d.setBackupVersions(backupVersions) +} + +// removeBackup removes the backup versions from Deployment that are in 'hosts' array parameter +func (d Deployment) removeBackup(processNames []string, log *zap.SugaredLogger) { + backupVersions := d.getBackupVersions() + updatedBackupVersions := make([]interface{}, 0) + initialLength := len(processNames) + hostNames := d.getProcessesHostNames(processNames) + for _, b := range backupVersions { + backup := b.(map[string]interface{}) + hostname := backup["hostname"].(string) + if !stringutil.Contains(hostNames, hostname) { + updatedBackupVersions = append(updatedBackupVersions, b) + } else { + hostNames = stringutil.Remove(hostNames, hostname) + } + } + + if len(hostNames) != 0 { + // Note, that we don't error/warn here as there can be plenty of reasons why the config is not here (e.g. some + // process added to OM deployment manually that doesn't have corresponding backup config). Warn prints the + // stacktrace which looks quite scary + log.Infof("The following hosts were not removed from backup config as they were not found: %s", hostNames) + } else { + log.Debugf("Removed backup agent configuration for %d host(s)", initialLength) + } + d.setBackupVersions(updatedBackupVersions) +} + +// copyFirstProcessToNewPositions is used when scaling up replica set / set of mongos processes. Its main goal is to clone +// the sample existing deployment process as many times as the number of new processes to be added. The copies get +// the names of the "new" processes so that the following "mergeStandalone" operation could merge "Operator owned" information +// back into copies. This allows to keep all changes made by OM to existing processes and overwrite only the fields that +// Operator is responsible for. +// So if current RS deployment that came from OM has processes A, B, C and operator wants to scale up on 2 more members +// (meaning it wants to add X, Y processes) - then in the end of this function deployment will contain processes A, B, C, +// and X, Y where X, Y will be complete copies of A instead of names and aliases. +// "processes" is the array of "Operator view" processes (so for the example above they will be "A, B, C, X, Y" +// "idxOfFirstNewMember" is the index of the first NEW member. So for the example above it will be 3 +func (d Deployment) copyFirstProcessToNewPositions(processes []Process, idxOfFirstNewMember int, log *zap.SugaredLogger) error { + newProcesses := processes[idxOfFirstNewMember:] + + var sampleProcess Process + + // The sample process must be the one that exist in OM deployment - so if for some reason OM added some + // processes to Deployment (and they won't get into merged deployment) - we must find the first one matching + // As an example let's consider the RS that contained processes A, B, C and then OM UI removed the processes A and B + // So the "processes" array (which is Kubernetes view of RS) will still contain A, B, C, .. so in the end the sample + // process will be C (as this is the only process that intersects in the "OM view" and "Kubernetes view" and it will + // get into final deployment + for _, v := range processes { + if d.getProcessByName(v.Name()) != nil { + sampleProcess = *d.getProcessByName(v.Name()) + break + } + } + // If sample process has not been found - that means that all processes in OM deployment are some fake - we'll remove + // them anyway and there is no need in merging + // Example: OM UI removed A, B, C and added P, T, R, but Kubernetes will still try to create the RS of A, B, C - and + // will remove faked processes in the end. So no OM "sample" would exist in this case as all processes will be brand + // new + if sampleProcess == nil { + return nil + } + + for _, p := range newProcesses { + sampleProcessCopy, err := sampleProcess.DeepCopy() + if err != nil { + return xerrors.Errorf("failed to make a copy of Process %s: %w", sampleProcess.Name(), err) + } + sampleProcessCopy.setName(p.Name()) + + // add here other attributes that mustn's be copied (may be some others should be added here) + delete(sampleProcessCopy, "alias") + + // This is just fool protection - if for some reasons the process already exists in deployment (it mustn't actually) + // then we don't add the copy of sample one + if d.getProcessByName(p.Name()) == nil { + d.addProcess(sampleProcessCopy) + log.Debugw("Added the copy of the process to the end of deployment processes", "process name", + sampleProcess.Name(), "new process name", sampleProcessCopy.Name()) + } + } + return nil +} + +func (d Deployment) processesHaveInternalClusterAuthentication(processes []Process) bool { + for _, p := range processes { + if p.HasInternalClusterAuthentication() { + return true + } + } + return false +} + +// limitVotingMembers ensures the number of voting members in the replica set is not more than 7 members +func (d Deployment) limitVotingMembers(rsName string) { + r := d.getReplicaSetByName(rsName) + + numberOfVotingMembers := 0 + for _, v := range r.Members() { + if v.Votes() > 0 { + numberOfVotingMembers++ + } + if numberOfVotingMembers > 7 { + v.setVotes(0).setPriority(0) + } + } +} + +func (d Deployment) deepCopy() Deployment { + var depCopy Deployment + + depCopy, err := util.MapDeepCopy(d) + if err != nil { + panic(err) + } + return depCopy +} diff --git a/controllers/om/deployment/om_deployment.go b/controllers/om/deployment/om_deployment.go new file mode 100644 index 000000000..845ec3d59 --- /dev/null +++ b/controllers/om/deployment/om_deployment.go @@ -0,0 +1,10 @@ +package deployment + +import ( + "fmt" +) + +// Link returns the deployment link given the baseUrl and groupId. +func Link(url, groupId string) string { + return fmt.Sprintf("%s/v2/%s", url, groupId) +} diff --git a/controllers/om/deployment/om_deployment_test.go b/controllers/om/deployment/om_deployment_test.go new file mode 100644 index 000000000..110f184bb --- /dev/null +++ b/controllers/om/deployment/om_deployment_test.go @@ -0,0 +1,39 @@ +package deployment + +import ( + "testing" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/controllers/om/replicaset" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/mock" + "github.com/stretchr/testify/assert" + "go.uber.org/zap" +) + +func init() { + logger, _ := zap.NewDevelopment() + zap.ReplaceGlobals(logger) + mock.InitDefaultEnvVariables() +} + +// TestPrepareScaleDown_OpsManagerRemovedMember tests the situation when during scale down some replica set member doesn't +// exist (this can happen when for example the member was removed from Ops Manager manually). The exception is handled +// and only the existing member is marked as unvoted +func TestPrepareScaleDown_OpsManagerRemovedMember(t *testing.T) { + // This is deployment with 2 members (emulating that OpsManager removed the 3rd one) + rs := mdbv1.NewReplicaSetBuilder().SetName("bam").SetMembers(2).Build() + oldDeployment := CreateFromReplicaSet(rs) + mockedOmConnection := om.NewMockedOmConnection(oldDeployment) + + // We try to prepare two members for scale down, but one of them will fail (bam-2) + rsWithThreeMembers := map[string][]string{"bam": {"bam-1", "bam-2"}} + assert.NoError(t, replicaset.PrepareScaleDownFromMap(mockedOmConnection, rsWithThreeMembers, zap.S())) + + expectedDeployment := CreateFromReplicaSet(rs) + + assert.NoError(t, expectedDeployment.MarkRsMembersUnvoted("bam", []string{"bam-1"})) + + mockedOmConnection.CheckNumberOfUpdateRequests(t, 1) + mockedOmConnection.CheckDeployment(t, expectedDeployment) +} diff --git a/controllers/om/deployment/testing_utils.go b/controllers/om/deployment/testing_utils.go new file mode 100644 index 000000000..322cdb613 --- /dev/null +++ b/controllers/om/deployment/testing_utils.go @@ -0,0 +1,43 @@ +package deployment + +import ( + "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/controllers/om/replicaset" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/construct" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" + "go.uber.org/zap" +) + +// CreateFromReplicaSet builds the replica set for the automation config +// based on the given MongoDB replica set. +// NOTE: This method is only used for testing. +// But we can't move in a *_test file since it is called from tests in +// different packages. And test files are only compiled +// when testing that specific package +// https://github.com/golang/go/issues/10184#issuecomment-84465873 +func CreateFromReplicaSet(rs *mdb.MongoDB) om.Deployment { + sts := construct.DatabaseStatefulSet(*rs, construct.ReplicaSetOptions( + func(options *construct.DatabaseStatefulSetOptions) { + options.PodVars = &env.PodEnvVars{ProjectID: "abcd"} + + }, + ), nil) + d := om.NewDeployment() + + lastConfig, err := rs.GetLastAdditionalMongodConfigByType(mdb.ReplicaSetConfig) + if err != nil { + panic(err) + } + + d.MergeReplicaSet( + replicaset.BuildFromStatefulSet(sts, rs.GetSpec()), + rs.Spec.AdditionalMongodConfig.ToMap(), + lastConfig.ToMap(), + nil, + ) + d.AddMonitoringAndBackup(zap.S(), rs.Spec.GetSecurity().IsTLSEnabled(), util.CAFilePathInContainer) + d.ConfigureTLS(rs.Spec.GetSecurity(), util.CAFilePathInContainer) + return d +} diff --git a/controllers/om/deployment_test.go b/controllers/om/deployment_test.go new file mode 100644 index 000000000..c2932738e --- /dev/null +++ b/controllers/om/deployment_test.go @@ -0,0 +1,824 @@ +package om + +import ( + "fmt" + "io/ioutil" + "strconv" + "strings" + "testing" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/automationconfig" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func init() { + logger, _ := zap.NewDevelopment() + zap.ReplaceGlobals(logger) +} + +// First time merge adds the new standalone +// second invocation doesn't add new node as the existing standalone is found (by name) and the data is merged +func TestMergeStandalone(t *testing.T) { + d := NewDeployment() + mergeStandalone(d, createStandalone()) + + assert.Len(t, d.getProcesses(), 1) + + d["version"] = 5 + d.getProcesses()[0]["alias"] = "alias" + d.getProcesses()[0]["hostname"] = "foo" + d.getProcesses()[0]["authSchemaVersion"] = 10 + d.getProcesses()[0]["featureCompatibilityVersion"] = "bla" + + mergeStandalone(d, createStandalone()) + + assert.Len(t, d.getProcesses(), 1) + + expected := createStandalone() + + // fields which are not owned by OM-Kube should be left unchanged + assert.Equal(t, d["version"], 5) + expected["alias"] = "alias" + + assert.Equal(t, &expected, d.getProcessByName(expected.Name())) +} + +// First merge results in just adding the ReplicaSet +// Second merge performs real merge operation +func TestMergeReplicaSet(t *testing.T) { + d := NewDeployment() + mergeReplicaSet(d, "fooRs", createReplicaSetProcesses("fooRs")) + expectedRs := buildRsByProcesses("fooRs", createReplicaSetProcesses("fooRs")) + + assert.Len(t, d.getProcesses(), 3) + assert.Len(t, d.getReplicaSets(), 1) + assert.Len(t, d.getReplicaSets()[0].Members(), 3) + assert.Equal(t, d.getReplicaSets()[0], expectedRs.Rs) + + // Now the deployment "gets updated" from external - new node is added and one is removed - this should be fixed + // by merge + newProcess := NewMongodProcess(3, "foo", "bar", &mdbv1.AdditionalMongodConfig{}, &mdbv1.NewStandaloneBuilder().Build().Spec, "") + + d.getProcesses()[0]["processType"] = ProcessTypeMongos // this will be overriden + d.getProcesses()[1].EnsureNetConfig()["MaxIncomingConnections"] = 20 // this will be left as-is + d.getReplicaSets()[0]["protocolVersion"] = 10 // this field will be overriden by Operator + d.getReplicaSets()[0].setMembers(d.getReplicaSets()[0].Members()[0:2]) // "removing" the last node in replicaset + d.getReplicaSets()[0].addMember(newProcess, "", automationconfig.MemberOptions{}) // "adding" some new node + d.getReplicaSets()[0].Members()[0]["arbiterOnly"] = true // changing data for first node + + mergeReplicaSet(d, "fooRs", createReplicaSetProcesses("fooRs")) + + assert.Len(t, d.getProcesses(), 3) + assert.Len(t, d.getReplicaSets(), 1) + + expectedRs = buildRsByProcesses("fooRs", createReplicaSetProcesses("fooRs")) + expectedRs.Rs.Members()[0]["arbiterOnly"] = true + expectedRs.Processes[1].EnsureNetConfig()["MaxIncomingConnections"] = 20 + + checkReplicaSet(t, d, expectedRs) +} + +// Checking that on scale down the old processes are removed +func TestMergeReplica_ScaleDown(t *testing.T) { + d := NewDeployment() + + mergeReplicaSet(d, "someRs", createReplicaSetProcesses("someRs")) + assert.Len(t, d.getProcesses(), 3) + assert.Len(t, d.getReplicaSets()[0].Members(), 3) + + // "scale down" + scaledDownRsProcesses := createReplicaSetProcesses("someRs")[0:2] + mergeReplicaSet(d, "someRs", scaledDownRsProcesses) + + assert.Len(t, d.getProcesses(), 2) + assert.Len(t, d.getReplicaSets()[0].Members(), 2) + + // checking that the last member was removed + rsProcesses := buildRsByProcesses("someRs", createReplicaSetProcesses("someRs")).Processes + assert.Contains(t, d.getProcesses(), rsProcesses[0]) + assert.Contains(t, d.getProcesses(), rsProcesses[1]) + assert.NotContains(t, d.getProcesses(), rsProcesses[2]) +} + +// TestMergeReplicaSet_MergeFirstProcess checks that if the replica set is scaled up - then all OM changes to existing +// processes (more precisely - to the first member) are copied to new members +func TestMergeReplicaSet_MergeFirstProcess(t *testing.T) { + d := NewDeployment() + + mergeReplicaSet(d, "fooRs", createReplicaSetProcesses("fooRs")) + mergeReplicaSet(d, "anotherRs", createReplicaSetProcesses("anotherRs")) + + // Now the first process (and usually all others in practice) are changed by OM + d.getProcesses()[0].EnsureNetConfig()["MaxIncomingConnections"] = 20 + d.getProcesses()[0]["backupRestoreUrl"] = "http://localhost:7890" + d.getProcesses()[0]["logRotate"] = map[string]int{"sizeThresholdMB": 3000, "timeThresholdHrs": 12} + d.getProcesses()[0]["kerberos"] = map[string]string{"keytab": "123456"} + + // Now we merged the scaled up RS + mergeReplicaSet(d, "fooRs", createReplicaSetProcessesCount(5, "fooRs")) + + assert.Len(t, d.getProcesses(), 8) + assert.Len(t, d.getReplicaSets(), 2) + + expectedRs := buildRsByProcesses("fooRs", createReplicaSetProcessesCount(5, "fooRs")) + + // Verifying that the first process was merged with new ones + for _, i := range []int{0, 3, 4} { + expectedRs.Processes[i].EnsureNetConfig()["MaxIncomingConnections"] = 20 + expectedRs.Processes[i]["backupRestoreUrl"] = "http://localhost:7890" + expectedRs.Processes[i]["logRotate"] = map[string]int{"sizeThresholdMB": 3000, "timeThresholdHrs": 12} + expectedRs.Processes[i]["kerberos"] = map[string]string{"keytab": "123456"} + } + + // The other replica set must be the same + checkReplicaSet(t, d, buildRsByProcesses("anotherRs", createReplicaSetProcesses("anotherRs"))) + checkReplicaSet(t, d, expectedRs) +} + +func TestConfigureSSL_Deployment(t *testing.T) { + d := Deployment{} + d.ConfigureTLS(&mdbv1.Security{TLSConfig: &mdbv1.TLSConfig{Enabled: true}}, util.CAFilePathInContainer) + expectedSSLConfig := map[string]interface{}{ + "CAFilePath": "/mongodb-automation/ca.pem", + } + assert.Equal(t, expectedSSLConfig, d["tls"].(map[string]interface{})) + + d.ConfigureTLS(&mdbv1.Security{}, util.CAFilePathInContainer) + assert.Equal(t, d["tls"], map[string]any{"clientCertificateMode": string(automationconfig.ClientCertificateModeOptional)}) +} + +func TestTLSConfigurationWillBeDisabled(t *testing.T) { + d := Deployment{} + d.ConfigureTLS(&mdbv1.Security{TLSConfig: &mdbv1.TLSConfig{Enabled: false}}, util.CAFilePathInContainer) + + assert.False(t, d.TLSConfigurationWillBeDisabled(&mdbv1.Security{TLSConfig: &mdbv1.TLSConfig{Enabled: false}})) + assert.False(t, d.TLSConfigurationWillBeDisabled(&mdbv1.Security{TLSConfig: &mdbv1.TLSConfig{Enabled: true}})) + + d = Deployment{} + d.ConfigureTLS(&mdbv1.Security{TLSConfig: &mdbv1.TLSConfig{Enabled: true}}, util.CAFilePathInContainer) + + assert.False(t, d.TLSConfigurationWillBeDisabled(&mdbv1.Security{TLSConfig: &mdbv1.TLSConfig{Enabled: true}})) + assert.True(t, d.TLSConfigurationWillBeDisabled(&mdbv1.Security{TLSConfig: &mdbv1.TLSConfig{Enabled: false}})) +} + +// TestMergeDeployment_BigReplicaset ensures that adding a big replica set (> 7 members) works correctly and no more than +// 7 voting members are added +func TestMergeDeployment_BigReplicaset(t *testing.T) { + omDeployment := NewDeployment() + rs := buildRsByProcesses("my-rs", createReplicaSetProcessesCount(8, "my-rs")) + checkNumberOfVotingMembers(t, rs, 8, 8) + + omDeployment.MergeReplicaSet(rs, nil, nil, zap.S()) + checkNumberOfVotingMembers(t, rs, 7, 8) + + // Now OM user "has changed" votes for some of the members - this must stay the same after merge + omDeployment.getReplicaSets()[0].Members()[2].setVotes(0).setPriority(0) + omDeployment.getReplicaSets()[0].Members()[4].setVotes(0).setPriority(0) + + omDeployment.MergeReplicaSet(rs, nil, nil, zap.S()) + checkNumberOfVotingMembers(t, rs, 5, 8) + + // Now operator scales up by one - the "OM votes" should not suffer, but total number of votes will increase by one + rsToMerge := buildRsByProcesses("my-rs", createReplicaSetProcessesCount(9, "my-rs")) + rsToMerge.Rs.Members()[2].setVotes(0).setPriority(0) + rsToMerge.Rs.Members()[4].setVotes(0).setPriority(0) + rsToMerge.Rs.Members()[7].setVotes(0).setPriority(0) + omDeployment.MergeReplicaSet(rsToMerge, nil, nil, zap.S()) + checkNumberOfVotingMembers(t, rs, 6, 9) + + // Now operator scales up by two - the "OM votes" should not suffer, but total number of votes will increase by one + // only as 7 is the upper limit + rsToMerge = buildRsByProcesses("my-rs", createReplicaSetProcessesCount(11, "my-rs")) + rsToMerge.Rs.Members()[2].setVotes(0).setPriority(0) + rsToMerge.Rs.Members()[4].setVotes(0).setPriority(0) + + omDeployment.MergeReplicaSet(rsToMerge, nil, nil, zap.S()) + checkNumberOfVotingMembers(t, rs, 7, 11) + assert.Equal(t, 0, omDeployment.getReplicaSets()[0].Members()[2].Votes()) + assert.Equal(t, 0, omDeployment.getReplicaSets()[0].Members()[4].Votes()) + assert.Equal(t, float32(0), omDeployment.getReplicaSets()[0].Members()[2].Priority()) + assert.Equal(t, float32(0), omDeployment.getReplicaSets()[0].Members()[4].Priority()) +} + +func TestGetAllProcessNames_MergedReplicaSetsAndShardedClusters(t *testing.T) { + d := NewDeployment() + rs0 := buildRsByProcesses("my-rs", createReplicaSetProcessesCount(3, "my-rs")) + + d.MergeReplicaSet(rs0, nil, nil, zap.S()) + assert.Equal(t, []string{"my-rs-0", "my-rs-1", "my-rs-2"}, d.GetAllProcessNames()) + + rs1 := buildRsByProcesses("another-rs", createReplicaSetProcessesCount(5, "another-rs")) + d.MergeReplicaSet(rs1, nil, nil, zap.S()) + + assert.Equal( + t, + []string{ + "my-rs-0", "my-rs-1", "my-rs-2", + "another-rs-0", "another-rs-1", "another-rs-2", "another-rs-3", "another-rs-4", + }, + d.GetAllProcessNames()) + + configRs := createConfigSrvRs("configSrv", false) + + mergeOpts := DeploymentShardedClusterMergeOptions{ + Name: "cluster", + MongosProcesses: createMongosProcesses(3, "pretty", ""), + ConfigServerRs: configRs, + Shards: createShards("myShard"), + Finalizing: false, + } + d.MergeShardedCluster(mergeOpts) + + assert.Equal( + t, + []string{ + "my-rs-0", "my-rs-1", "my-rs-2", + "another-rs-0", "another-rs-1", "another-rs-2", "another-rs-3", "another-rs-4", + "pretty0", "pretty1", "pretty2", + "configSrv-0", "configSrv-1", "configSrv-2", + "myShard-0-0", "myShard-0-1", "myShard-0-2", + "myShard-1-0", "myShard-1-1", "myShard-1-2", + "myShard-2-0", "myShard-2-1", "myShard-2-2", + }, + d.GetAllProcessNames()) + +} + +func TestGetAllProcessNames_MergedShardedClusters(t *testing.T) { + d := NewDeployment() + + configRs := createConfigSrvRs("configSrv", false) + mergeOpts := DeploymentShardedClusterMergeOptions{ + Name: "cluster", + MongosProcesses: createMongosProcesses(3, "pretty", ""), + ConfigServerRs: configRs, + Shards: createShards("myShard"), + Finalizing: false, + } + d.MergeShardedCluster(mergeOpts) + assert.Equal( + t, + []string{ + "pretty0", "pretty1", "pretty2", + "configSrv-0", "configSrv-1", "configSrv-2", + "myShard-0-0", "myShard-0-1", "myShard-0-2", + "myShard-1-0", "myShard-1-1", "myShard-1-2", + "myShard-2-0", "myShard-2-1", "myShard-2-2", + }, + d.GetAllProcessNames(), + ) + + mergeOpts = DeploymentShardedClusterMergeOptions{ + Name: "anotherCluster", + MongosProcesses: createMongosProcesses(3, "anotherMongos", ""), + ConfigServerRs: configRs, + Shards: createShards("anotherClusterSh"), + Finalizing: false, + } + d.MergeShardedCluster(mergeOpts) + assert.Equal( + t, + []string{ + "pretty0", "pretty1", "pretty2", + "configSrv-0", "configSrv-1", "configSrv-2", + "myShard-0-0", "myShard-0-1", "myShard-0-2", + "myShard-1-0", "myShard-1-1", "myShard-1-2", + "myShard-2-0", "myShard-2-1", "myShard-2-2", + "anotherMongos0", "anotherMongos1", "anotherMongos2", + "anotherClusterSh-0-0", "anotherClusterSh-0-1", "anotherClusterSh-0-2", + "anotherClusterSh-1-0", "anotherClusterSh-1-1", "anotherClusterSh-1-2", + "anotherClusterSh-2-0", "anotherClusterSh-2-1", "anotherClusterSh-2-2", + }, + d.GetAllProcessNames(), + ) +} + +func TestDeploymentCountIsCorrect(t *testing.T) { + d := NewDeployment() + + rs0 := buildRsByProcesses("my-rs", createReplicaSetProcessesCount(3, "my-rs")) + d.MergeReplicaSet(rs0, nil, nil, zap.S()) + + excessProcesses := d.GetNumberOfExcessProcesses("my-rs") + // There's only one resource in this deployment + assert.Equal(t, 0, excessProcesses) + + rs1 := buildRsByProcesses("my-rs-second", createReplicaSetProcessesCount(3, "my-rs-second")) + d.MergeReplicaSet(rs1, nil, nil, zap.S()) + excessProcesses = d.GetNumberOfExcessProcesses("my-rs") + + // another replica set was added to the deployment. 3 processes do not belong to this one + assert.Equal(t, 3, excessProcesses) + + configRs := createConfigSrvRs("config", false) + + mergeOpts := DeploymentShardedClusterMergeOptions{ + Name: "sc001", + MongosProcesses: createMongosProcesses(3, "mongos", ""), + ConfigServerRs: configRs, + Shards: createShards("sc001"), + Finalizing: false, + } + + _, _ = d.MergeShardedCluster(mergeOpts) + excessProcesses = d.GetNumberOfExcessProcesses("my-rs") + + // a Sharded Cluster was added, plenty of processes do not belong to "my-rs" anymore + assert.Equal(t, 18, excessProcesses) + + // This unknown process does not belong in here + excessProcesses = d.GetNumberOfExcessProcesses("some-unknown-name") + + // a Sharded Cluster was added, plenty of processes do not belong to "my-rs" anymore + assert.Equal(t, 21, excessProcesses) + + excessProcesses = d.GetNumberOfExcessProcesses("sc001") + // There are 6 processes that do not belong to the sc001 sharded cluster + assert.Equal(t, 6, excessProcesses) +} + +func TestGetNumberOfExcessProcesses_ShardedClusterScaleDown(t *testing.T) { + d := NewDeployment() + configRs := createConfigSrvRs("config", false) + + mergeOpts := DeploymentShardedClusterMergeOptions{ + Name: "sc001", + MongosProcesses: createMongosProcesses(3, "mongos", ""), + ConfigServerRs: configRs, + Shards: createShards("sc001"), + Finalizing: false, + } + + _, _ = d.MergeShardedCluster(mergeOpts) + assert.Len(t, d.getShardedClusterByName("sc001").shards(), 3) + assert.Len(t, d.getReplicaSets(), 4) + assert.Equal(t, 0, d.GetNumberOfExcessProcesses("sc001")) + + // Now we are "scaling down" the sharded cluster - so junk replica sets will appear - this is still ok + twoShards := createShards("sc001")[0:2] + + mergeOpts = DeploymentShardedClusterMergeOptions{ + Name: "sc001", + MongosProcesses: createMongosProcesses(3, "mongos", ""), + ConfigServerRs: configRs, + Shards: twoShards, + Finalizing: false, + } + + _, _ = d.MergeShardedCluster(mergeOpts) + assert.Len(t, d.getShardedClusterByName("sc001").shards(), 2) + assert.Len(t, d.getReplicaSets(), 4) + + assert.Equal(t, 0, d.GetNumberOfExcessProcesses("sc001")) +} + +func TestIsShardOf(t *testing.T) { + clusterName := "my-shard" + assert.True(t, isShardOfShardedCluster(clusterName, "my-shard-0")) + assert.True(t, isShardOfShardedCluster(clusterName, "my-shard-3")) + assert.True(t, isShardOfShardedCluster(clusterName, "my-shard-9")) + assert.True(t, isShardOfShardedCluster(clusterName, "my-shard-10")) + assert.True(t, isShardOfShardedCluster(clusterName, "my-shard-23")) + assert.True(t, isShardOfShardedCluster(clusterName, "my-shard-452")) + + assert.False(t, isShardOfShardedCluster(clusterName, "my-shard")) + assert.False(t, isShardOfShardedCluster(clusterName, "my-my-shard")) + assert.False(t, isShardOfShardedCluster(clusterName, "my-shard-s")) + assert.False(t, isShardOfShardedCluster(clusterName, "my-shard-1-0")) + assert.False(t, isShardOfShardedCluster(clusterName, "mmy-shard-1")) + assert.False(t, isShardOfShardedCluster(clusterName, "my-shard-1s")) +} + +func TestProcessBelongsToReplicaSet(t *testing.T) { + d := NewDeployment() + rs0 := buildRsByProcesses("my-rs", createReplicaSetProcessesCount(3, "my-rs")) + d.MergeReplicaSet(rs0, nil, nil, zap.S()) + + assert.True(t, d.ProcessBelongsToResource("my-rs-0", "my-rs")) + assert.True(t, d.ProcessBelongsToResource("my-rs-1", "my-rs")) + assert.True(t, d.ProcessBelongsToResource("my-rs-2", "my-rs")) + + // Process does not belong if resource does not exist + assert.False(t, d.ProcessBelongsToResource("unknown-0", "unknown")) +} + +func TestProcessBelongsToShardedCluster(t *testing.T) { + d := NewDeployment() + configRs := createConfigSrvRs("config", false) + mergeOpts := DeploymentShardedClusterMergeOptions{ + Name: "sh001", + MongosProcesses: createMongosProcesses(3, "mongos", ""), + ConfigServerRs: configRs, + Shards: createShards("shards"), + Finalizing: false, + } + + d.MergeShardedCluster(mergeOpts) + + // Config Servers + assert.True(t, d.ProcessBelongsToResource("config-0", "sh001")) + assert.True(t, d.ProcessBelongsToResource("config-1", "sh001")) + assert.True(t, d.ProcessBelongsToResource("config-2", "sh001")) + + // Does not belong! + assert.False(t, d.ProcessBelongsToResource("config-3", "sh001")) + + // Mongos + assert.True(t, d.ProcessBelongsToResource("mongos0", "sh001")) + assert.True(t, d.ProcessBelongsToResource("mongos1", "sh001")) + assert.True(t, d.ProcessBelongsToResource("mongos2", "sh001")) + // Does not belong! + assert.False(t, d.ProcessBelongsToResource("mongos3", "sh001")) + + // Shard members + assert.True(t, d.ProcessBelongsToResource("shards-0-0", "sh001")) + assert.True(t, d.ProcessBelongsToResource("shards-0-1", "sh001")) + assert.True(t, d.ProcessBelongsToResource("shards-0-2", "sh001")) + + // Does not belong! + assert.False(t, d.ProcessBelongsToResource("shards-0-3", "sh001")) + // Shard members + assert.True(t, d.ProcessBelongsToResource("shards-1-0", "sh001")) + assert.True(t, d.ProcessBelongsToResource("shards-1-1", "sh001")) + assert.True(t, d.ProcessBelongsToResource("shards-1-2", "sh001")) + + // Does not belong! + assert.False(t, d.ProcessBelongsToResource("shards-1-3", "sh001")) + // Shard members + assert.True(t, d.ProcessBelongsToResource("shards-2-0", "sh001")) + assert.True(t, d.ProcessBelongsToResource("shards-2-1", "sh001")) + assert.True(t, d.ProcessBelongsToResource("shards-2-2", "sh001")) + + // Does not belong! + assert.False(t, d.ProcessBelongsToResource("shards-2-3", "sh001")) +} + +func TestDeploymentMinimumMajorVersion(t *testing.T) { + d0 := NewDeployment() + rs0Processes := createReplicaSetProcessesCount(3, "my-rs") + rs0 := buildRsByProcesses("my-rs", rs0Processes) + d0.MergeReplicaSet(rs0, nil, nil, zap.S()) + + assert.Equal(t, uint64(3), d0.MinimumMajorVersion()) + + d1 := NewDeployment() + rs1Processes := createReplicaSetProcessesCount(3, "my-rs") + rs1Processes[0]["featureCompatibilityVersion"] = "2.4" + rs1 := buildRsByProcesses("my-rs", rs1Processes) + d1.MergeReplicaSet(rs1, nil, nil, zap.S()) + + assert.Equal(t, uint64(2), d1.MinimumMajorVersion()) + + d2 := NewDeployment() + rs2Processes := createReplicaSetProcessesCountEnt(3, "my-rs") + rs2 := buildRsByProcesses("my-rs", rs2Processes) + d2.MergeReplicaSet(rs2, nil, nil, zap.S()) + + assert.Equal(t, uint64(3), d2.MinimumMajorVersion()) +} + +// TestConfiguringTlsProcessFromOpsManager ensures that if OM sends 'tls' fields for processes and deployments - +// they are moved to 'ssl' +func TestConfiguringTlsProcessFromOpsManager(t *testing.T) { + data, err := ioutil.ReadFile("testdata/deployment_tls.json") + assert.NoError(t, err) + deployment, err := BuildDeploymentFromBytes(data) + assert.NoError(t, err) + + assert.Contains(t, deployment, "tls") + + for _, p := range deployment.getProcesses() { + assert.Contains(t, p.EnsureNetConfig(), "tls") + } +} + +func TestAddMonitoring(t *testing.T) { + d := NewDeployment() + + rs0 := buildRsByProcesses("my-rs", createReplicaSetProcessesCount(3, "my-rs")) + d.MergeReplicaSet(rs0, nil, nil, zap.S()) + d.AddMonitoring(zap.S(), false, util.CAFilePathInContainer) + + expectedMonitoringVersions := []interface{}{ + map[string]interface{}{"hostname": "my-rs-0.some.host", "name": MonitoringAgentDefaultVersion}, + map[string]interface{}{"hostname": "my-rs-1.some.host", "name": MonitoringAgentDefaultVersion}, + map[string]interface{}{"hostname": "my-rs-2.some.host", "name": MonitoringAgentDefaultVersion}, + } + assert.Equal(t, expectedMonitoringVersions, d.getMonitoringVersions()) + + // adding again - nothing changes + d.AddMonitoring(zap.S(), false, util.CAFilePathInContainer) + assert.Equal(t, expectedMonitoringVersions, d.getMonitoringVersions()) +} + +func TestAddMonitoringTls(t *testing.T) { + d := NewDeployment() + + rs0 := buildRsByProcesses("my-rs", createReplicaSetProcessesCount(3, "my-rs")) + d.MergeReplicaSet(rs0, nil, nil, zap.S()) + d.AddMonitoring(zap.S(), true, util.CAFilePathInContainer) + + expectedAdditionalParams := map[string]string{ + "useSslForAllConnections": "true", + "sslTrustedServerCertificates": util.CAFilePathInContainer, + } + + expectedMonitoringVersions := []interface{}{ + map[string]interface{}{"hostname": "my-rs-0.some.host", "name": MonitoringAgentDefaultVersion, "additionalParams": expectedAdditionalParams}, + map[string]interface{}{"hostname": "my-rs-1.some.host", "name": MonitoringAgentDefaultVersion, "additionalParams": expectedAdditionalParams}, + map[string]interface{}{"hostname": "my-rs-2.some.host", "name": MonitoringAgentDefaultVersion, "additionalParams": expectedAdditionalParams}, + } + assert.Equal(t, expectedMonitoringVersions, d.getMonitoringVersions()) + + // adding again - nothing changes + d.AddMonitoring(zap.S(), true, util.CAFilePathInContainer) + assert.Equal(t, expectedMonitoringVersions, d.getMonitoringVersions()) +} + +func TestAddBackup(t *testing.T) { + d := NewDeployment() + + rs0 := buildRsByProcesses("my-rs", createReplicaSetProcessesCount(3, "my-rs")) + d.MergeReplicaSet(rs0, nil, nil, zap.S()) + d.addBackup(zap.S()) + + expectedBackupVersions := []interface{}{ + map[string]interface{}{"hostname": "my-rs-0.some.host", "name": BackupAgentDefaultVersion}, + map[string]interface{}{"hostname": "my-rs-1.some.host", "name": BackupAgentDefaultVersion}, + map[string]interface{}{"hostname": "my-rs-2.some.host", "name": BackupAgentDefaultVersion}, + } + assert.Equal(t, expectedBackupVersions, d.getBackupVersions()) + + // adding again - nothing changes + d.addBackup(zap.S()) + assert.Equal(t, expectedBackupVersions, d.getBackupVersions()) + +} + +// ************************ Methods for checking deployment units + +func checkShardedCluster(t *testing.T, d Deployment, expectedCluster ShardedCluster, replicaSetWithProcesses []ReplicaSetWithProcesses) { + checkShardedClusterCheckExtraReplicaSets(t, d, expectedCluster, replicaSetWithProcesses, true) +} + +func checkShardedClusterCheckExtraReplicaSets(t *testing.T, d Deployment, expectedCluster ShardedCluster, + expectedReplicaSets []ReplicaSetWithProcesses, checkExtraReplicaSets bool) { + cluster := d.getShardedClusterByName(expectedCluster.Name()) + + require.NotNil(t, cluster) + + assert.Equal(t, expectedCluster, *cluster) + + checkReplicaSets(t, d, expectedReplicaSets, checkExtraReplicaSets) + + if checkExtraReplicaSets { + // checking that no previous replica sets are left. For this we take the name of first shard and remove the last digit + firstShardName := expectedReplicaSets[0].Rs.Name() + i := 0 + for _, r := range d.getReplicaSets() { + if strings.HasPrefix(r.Name(), firstShardName[0:len(firstShardName)-1]) { + i++ + } + } + assert.Equal(t, len(expectedReplicaSets), i) + } +} + +func checkReplicaSets(t *testing.T, d Deployment, replicaSetWithProcesses []ReplicaSetWithProcesses, checkExtraProcesses bool) { + for _, r := range replicaSetWithProcesses { + if checkExtraProcesses { + checkReplicaSet(t, d, r) + } else { + checkReplicaSetCheckExtraProcesses(t, d, r, false) + } + } +} + +func checkReplicaSetCheckExtraProcesses(t *testing.T, d Deployment, expectedRs ReplicaSetWithProcesses, checkExtraProcesses bool) { + rs := d.getReplicaSetByName(expectedRs.Rs.Name()) + + require.NotNil(t, rs) + + assert.Equal(t, expectedRs.Rs, *rs) + rsPrefix := expectedRs.Rs.Name() + + found := 0 + totalMongods := 0 + for _, p := range d.getProcesses() { + for i, e := range expectedRs.Processes { + if p.ProcessType() == ProcessTypeMongod && p.Name() == e.Name() { + assert.Equal(t, e, p, "Process %d (%s) doesn't match! \nExpected: %v, \nReal: %v", i, p.Name(), e.json(), p.json()) + found++ + } + } + if p.ProcessType() == ProcessTypeMongod && strings.HasPrefix(p.Name(), rsPrefix) { + totalMongods++ + } + } + assert.Equalf(t, len(expectedRs.Processes), found, "Not all %s replicaSet processes are found!", expectedRs.Rs.Name()) + if checkExtraProcesses { + assert.Equalf(t, len(expectedRs.Processes), totalMongods, "Some excessive mongod processes are found for %s replicaSet!", expectedRs.Rs.Name()) + } +} + +func checkReplicaSet(t *testing.T, d Deployment, expectedRs ReplicaSetWithProcesses) { + checkReplicaSetCheckExtraProcesses(t, d, expectedRs, true) +} + +func checkProcess(t *testing.T, d Deployment, expectedProcess Process) { + assert.NotNil(t, d.getProcessByName(expectedProcess.Name())) + + for _, p := range d.getProcesses() { + if p.Name() == expectedProcess.Name() { + assert.Equal(t, expectedProcess, p) + break + } + } +} + +func checkMongoSProcesses(t *testing.T, allProcesses []Process, expectedMongosProcesses []Process) { + found := 0 + totalMongoses := 0 + + mongosPrefix := expectedMongosProcesses[0].Name()[0 : len(expectedMongosProcesses[0].Name())-1] + + for _, p := range allProcesses { + for _, e := range expectedMongosProcesses { + if p.ProcessType() == ProcessTypeMongos && p.Name() == e.Name() { + assert.Equal(t, e, p, "Actual: %v\n, Expected: %v", p.json(), e.json()) + found++ + } + } + if p.ProcessType() == ProcessTypeMongos && strings.HasPrefix(p.Name(), mongosPrefix) { + totalMongoses++ + } + } + assert.Equal(t, len(expectedMongosProcesses), found, "Not all mongos processes are found!") + assert.Equal(t, len(expectedMongosProcesses), totalMongoses, "Some excessive mongos processes are found!") +} + +func checkShardedClusterRemoved(t *testing.T, d Deployment, sc ShardedCluster, configRs ReplicaSetWithProcesses, shards []ReplicaSetWithProcesses) { + assert.Nil(t, d.getShardedClusterByName(sc.Name())) + + checkReplicaSetRemoved(t, d, configRs) + + for _, s := range shards { + checkReplicaSetRemoved(t, d, s) + } + + assert.Len(t, d.getMongosProcessesNames(sc.Name()), 0) +} + +func checkReplicaSetRemoved(t *testing.T, d Deployment, rs ReplicaSetWithProcesses) { + assert.Nil(t, d.getReplicaSetByName(rs.Rs.Name())) + + for _, p := range rs.Processes { + checkProcessRemoved(t, d, p.Name()) + } +} + +func checkProcessRemoved(t *testing.T, d Deployment, p string) { + assert.Nil(t, d.getProcessByName(p)) +} + +func checkNumberOfVotingMembers(t *testing.T, rs ReplicaSetWithProcesses, expectedNumberOfVotingMembers, totalNumberOfMembers int) { + count := 0 + for _, m := range rs.Rs.Members() { + if m.Votes() > 0 && m.Priority() > 0 { + count++ + } + } + assert.Equal(t, expectedNumberOfVotingMembers, count) + assert.Len(t, rs.Rs.Members(), totalNumberOfMembers) +} + +func createShards(name string) []ReplicaSetWithProcesses { + return createSpecificNumberOfShards(3, name) +} + +// ******************************** Methods for creating deployment units + +func createSpecificNumberOfShards(count int, name string) []ReplicaSetWithProcesses { + return createSpecificNumberOfShardsAndMongods(count, 3, name) +} + +func createShardsSpecificNumberOfMongods(count int, name string) []ReplicaSetWithProcesses { + return createSpecificNumberOfShardsAndMongods(3, count, name) +} + +func createSpecificNumberOfShardsAndMongods(countShards, countMongods int, name string) []ReplicaSetWithProcesses { + shards := make([]ReplicaSetWithProcesses, countShards) + for i := 0; i < countShards; i++ { + rsName := fmt.Sprintf("%s-%d", name, i) + options := make([]automationconfig.MemberOptions, countMongods) + shards[i] = NewReplicaSetWithProcesses( + NewReplicaSet(rsName, "3.6.3"), + createReplicaSetProcessesCount(countMongods, rsName), + options, + ) + } + return shards +} + +func buildRsByProcesses(rsName string, processes []Process) ReplicaSetWithProcesses { + options := make([]automationconfig.MemberOptions, len(processes)) + return NewReplicaSetWithProcesses( + NewReplicaSet(rsName, "3.6.3"), + processes, + options, + ) +} + +func createStandalone() Process { + return NewMongodProcess(0, "merchantsStandalone", "mongo1.some.host", &mdbv1.AdditionalMongodConfig{}, defaultMongoDBVersioned("3.6.3"), "") +} + +func createMongosProcesses(num int, name, clusterName string) []Process { + mongosProcesses := make([]Process, num) + + for i := 0; i < num; i++ { + idx := strconv.Itoa(i) + mongosProcesses[i] = NewMongosProcess(name+idx, "mongoS"+idx+".some.host", &mdbv1.AdditionalMongodConfig{}, defaultMongoDBVersioned("3.6.3"), "") + if clusterName != "" { + mongosProcesses[i].setCluster(clusterName) + } + } + return mongosProcesses +} + +func createReplicaSetProcesses(rsName string) []Process { + return createReplicaSetProcessesCount(3, rsName) +} + +func createReplicaSetProcessesCount(count int, rsName string) []Process { + rsMembers := make([]Process, count) + + for i := 0; i < count; i++ { + rsMembers[i] = NewMongodProcess(i, fmt.Sprintf("%s-%d", rsName, i), fmt.Sprintf("%s-%d.some.host", rsName, i), &mdbv1.AdditionalMongodConfig{}, defaultMongoDBVersioned("3.6.3"), "") + // Note that we don't specify the replicaset config for process + } + return rsMembers +} + +func createReplicaSetProcessesCountEnt(count int, rsName string) []Process { + rsMembers := make([]Process, count) + + for i := 0; i < count; i++ { + rsMembers[i] = NewMongodProcess(i, fmt.Sprintf("%s-%d", rsName, i), fmt.Sprintf("%s-%d.some.host", rsName, i), &mdbv1.AdditionalMongodConfig{}, defaultMongoDBVersioned("3.6.3-ent"), "") + // Note that we don't specify the replicaset config for process + } + return rsMembers +} + +func createConfigSrvRs(name string, check bool) ReplicaSetWithProcesses { + options := make([]automationconfig.MemberOptions, 3) + replicaSetWithProcesses := NewReplicaSetWithProcesses( + NewReplicaSet(name, "3.6.3"), + createReplicaSetProcesses(name), + options, + ) + + if check { + for _, p := range replicaSetWithProcesses.Processes { + p.setClusterRoleConfigSrv() + } + } + return replicaSetWithProcesses +} + +func createConfigSrvRsCount(count int, name string, check bool) ReplicaSetWithProcesses { + options := make([]automationconfig.MemberOptions, count) + replicaSetWithProcesses := NewReplicaSetWithProcesses( + NewReplicaSet(name, "3.6.3"), + createReplicaSetProcessesCount(count, name), + options, + ) + + if check { + for _, p := range replicaSetWithProcesses.Processes { + p.setClusterRoleConfigSrv() + } + } + return replicaSetWithProcesses +} + +func mergeReplicaSet(d Deployment, rsName string, rsProcesses []Process) ReplicaSetWithProcesses { + rs := buildRsByProcesses(rsName, rsProcesses) + d.MergeReplicaSet(rs, nil, nil, zap.S()) + return rs +} + +func mergeStandalone(d Deployment, s Process) Process { + d.MergeStandalone(s, nil, nil, zap.S()) + return s +} + +func defaultMongoDBVersioned(version string) mdbv1.DbSpec { + spec := mdbv1.NewReplicaSetBuilder().SetVersion(version).Build().Spec + return &spec +} diff --git a/controllers/om/depshardedcluster_test.go b/controllers/om/depshardedcluster_test.go new file mode 100644 index 000000000..c58b02c03 --- /dev/null +++ b/controllers/om/depshardedcluster_test.go @@ -0,0 +1,502 @@ +package om + +import ( + "testing" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/automationconfig" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" +) + +// TestMergeShardedClusterNoExisting that just merges the Sharded cluster into an empty deployment +func TestMergeShardedCluster_New(t *testing.T) { + d := NewDeployment() + + configRs := createConfigSrvRs("configSrv", false) + shards := createShards("myShard") + + mergeOpts := DeploymentShardedClusterMergeOptions{ + Name: "cluster", + MongosProcesses: createMongosProcesses(3, "pretty", ""), + ConfigServerRs: configRs, + Shards: shards, + Finalizing: false, + } + d.MergeShardedCluster(mergeOpts) + + require.Len(t, d.getProcesses(), 15) + require.Len(t, d.getReplicaSets(), 4) + for i := 0; i < 4; i++ { + require.Len(t, d.getReplicaSets()[i].Members(), 3) + } + checkMongoSProcesses(t, d.getProcesses(), createMongosProcesses(3, "pretty", "cluster")) + checkReplicaSet(t, d, createConfigSrvRs("configSrv", true)) + checkShardedCluster(t, d, NewShardedCluster("cluster", configRs.Rs.Name(), shards), createShards("myShard")) +} + +func TestMergeShardedCluster_ProcessesModified(t *testing.T) { + d := NewDeployment() + + shards := createShards("cluster") + + mergeOpts := DeploymentShardedClusterMergeOptions{ + Name: "cluster", + MongosProcesses: createMongosProcesses(3, "pretty", ""), + ConfigServerRs: createConfigSrvRs("configSrv", false), + Shards: shards, + Finalizing: false, + } + + d.MergeShardedCluster(mergeOpts) + + // OM "made" some changes (should not be overriden) + (*d.getProcessByName("pretty0"))["logRotate"] = map[string]int{"sizeThresholdMB": 1000, "timeThresholdHrs": 24} + + // These OM changes must be overriden + (*d.getProcessByName("configSrv-1")).Args()["sharding"] = map[string]interface{}{"clusterRole": "shardsrv", "archiveMovedChunks": true} + (*d.getProcessByName("cluster-1-1"))["hostname"] = "rubbish" + (*d.getProcessByName("pretty2")).SetLogPath("/doesnt/exist") + + // Final check - we create the expected configuration, add there correct OM changes and check for equality with merge + // result + mergeOpts = DeploymentShardedClusterMergeOptions{ + Name: "cluster", + MongosProcesses: createMongosProcesses(3, "pretty", ""), + ConfigServerRs: createConfigSrvRs("configSrv", false), + Shards: createShards("cluster"), + Finalizing: false, + } + + d.MergeShardedCluster(mergeOpts) + + expectedMongosProcesses := createMongosProcesses(3, "pretty", "cluster") + expectedMongosProcesses[0]["logRotate"] = map[string]int{"sizeThresholdMB": 1000, "timeThresholdHrs": 24} + expectedConfigrs := createConfigSrvRs("configSrv", true) + expectedConfigrs.Processes[1].Args()["sharding"] = map[string]interface{}{"clusterRole": "configsvr", "archiveMovedChunks": true} + + require.Len(t, d.getProcesses(), 15) + checkMongoSProcesses(t, d.getProcesses(), expectedMongosProcesses) + checkReplicaSet(t, d, expectedConfigrs) + checkShardedCluster(t, d, NewShardedCluster("cluster", expectedConfigrs.Rs.Name(), shards), createShards("cluster")) +} + +func TestMergeShardedCluster_ReplicaSetsModified(t *testing.T) { + d := NewDeployment() + + shards := createShards("cluster") + + mergeOpts := DeploymentShardedClusterMergeOptions{ + Name: "cluster", + MongosProcesses: createMongosProcesses(3, "pretty", ""), + ConfigServerRs: createConfigSrvRs("configSrv", false), + Shards: shards, + Finalizing: false, + } + d.MergeShardedCluster(mergeOpts) + + // OM "made" some changes (should not be overriden) + (*d.getReplicaSetByName("cluster-0"))["writeConcernMajorityJournalDefault"] = true + + // These OM changes must be overriden + (*d.getReplicaSetByName("cluster-0"))["protocolVersion"] = util.Int32Ref(2) + (*d.getReplicaSetByName("configSrv")).addMember( + NewMongodProcess(len(d.getReplicaSetByName("configSrv").Members()), "foo", "bar", &mdbv1.AdditionalMongodConfig{}, mdbv1.NewStandaloneBuilder().Build().GetSpec(), ""), "", automationconfig.MemberOptions{}, + ) + (*d.getReplicaSetByName("cluster-2")).setMembers(d.getReplicaSetByName("cluster-2").Members()[0:2]) + + // Final check - we create the expected configuration, add there correct OM changes and check for equality with merge + // result + configRs := createConfigSrvRs("configSrv", false) + mergeOpts = DeploymentShardedClusterMergeOptions{ + Name: "cluster", + MongosProcesses: createMongosProcesses(3, "pretty", ""), + ConfigServerRs: configRs, + Shards: createShards("cluster"), + Finalizing: false, + } + d.MergeShardedCluster(mergeOpts) + + expectedShards := createShards("cluster") + expectedShards[0].Rs["writeConcernMajorityJournalDefault"] = true + + require.Len(t, d.getProcesses(), 15) + require.Len(t, d.getReplicaSets(), 4) + for i := 0; i < 4; i++ { + require.Len(t, d.getReplicaSets()[i].Members(), 3) + } + checkMongoSProcesses(t, d.getProcesses(), createMongosProcesses(3, "pretty", "cluster")) + checkReplicaSet(t, d, createConfigSrvRs("configSrv", true)) + checkShardedCluster(t, d, NewShardedCluster("cluster", configRs.Rs.Name(), shards), expectedShards) +} + +func TestMergeShardedCluster_ShardedClusterModified(t *testing.T) { + d := NewDeployment() + + configRs := createConfigSrvRs("configSrv", false) + shards := createShards("myShard") + + mergeOpts := DeploymentShardedClusterMergeOptions{ + Name: "cluster", + MongosProcesses: createMongosProcesses(3, "pretty", ""), + ConfigServerRs: configRs, + Shards: shards, + Finalizing: false, + } + d.MergeShardedCluster(mergeOpts) + + // OM "made" some changes (should not be overriden) + (*d.getShardedClusterByName("cluster"))["managedSharding"] = true + (*d.getShardedClusterByName("cluster"))["collections"] = []map[string]interface{}{{"_id": "some", "unique": true}} + + // These OM changes must be overriden + (*d.getShardedClusterByName("cluster")).setConfigServerRsName("fake") + (*d.getShardedClusterByName("cluster")).setShards(d.getShardedClusterByName("cluster").shards()[0:2]) + (*d.getShardedClusterByName("cluster")).setShards(append(d.getShardedClusterByName("cluster").shards(), newShard("fakeShard"))) + + mergeReplicaSet(d, "fakeShard", createReplicaSetProcesses("fakeShard")) + + require.Len(t, d.getReplicaSets(), 5) + + // Final check - we create the expected configuration, add there correct OM changes and check for equality with merge + // result + configRs = createConfigSrvRs("configSrv", false) + mergeOpts = DeploymentShardedClusterMergeOptions{ + Name: "cluster", + MongosProcesses: createMongosProcesses(3, "pretty", ""), + ConfigServerRs: configRs, + Shards: createShards("myShard"), + Finalizing: false, + } + d.MergeShardedCluster(mergeOpts) + + expectedCluster := NewShardedCluster("cluster", configRs.Rs.Name(), shards) + expectedCluster["managedSharding"] = true + expectedCluster["collections"] = []map[string]interface{}{{"_id": "some", "unique": true}} + + // Note, that fake replicaset and it's processes haven't disappeared as we passed 'false' to 'MergeShardedCluster' + // which results in "draining" for redundant shards but not physical removal of replica sets + require.Len(t, d.getProcesses(), 18) + require.Len(t, d.getReplicaSets(), 5) + for i := 0; i < 4; i++ { + require.Len(t, d.getReplicaSets()[i].Members(), 3) + } + checkMongoSProcesses(t, d.getProcesses(), createMongosProcesses(3, "pretty", "cluster")) + checkReplicaSet(t, d, createConfigSrvRs("configSrv", true)) + checkShardedCluster(t, d, expectedCluster, createShards("myShard")) +} + +// TestMergeShardedCluster_ShardAdded checks the scenario of incrementing the number of shards +func TestMergeShardedCluster_ShardsAdded(t *testing.T) { + d := NewDeployment() + + configRs := createConfigSrvRs("configSrv", false) + shards := createShards("cluster") + mergeOpts := DeploymentShardedClusterMergeOptions{ + Name: "cluster", + MongosProcesses: createMongosProcesses(3, "pretty", ""), + ConfigServerRs: configRs, + Shards: shards, + Finalizing: false, + } + d.MergeShardedCluster(mergeOpts) + + shards = createSpecificNumberOfShards(5, "cluster") + mergeOpts = DeploymentShardedClusterMergeOptions{ + Name: "cluster", + MongosProcesses: createMongosProcesses(3, "pretty", ""), + ConfigServerRs: configRs, + Shards: shards, + Finalizing: false, + } + d.MergeShardedCluster(mergeOpts) + checkShardedCluster(t, d, NewShardedCluster("cluster", configRs.Rs.Name(), shards), shards) +} + +// TestMergeShardedCluster_ShardRemoved checks the scenario of decrementing the number of shards +// It creates a sharded cluster with 5 shards and scales down to 3 +func TestMergeShardedCluster_ShardsRemoved(t *testing.T) { + d := NewDeployment() + + configRs := createConfigSrvRs("configSrv", false) + shards := createSpecificNumberOfShards(5, "cluster") + + mergeOpts := DeploymentShardedClusterMergeOptions{ + Name: "cluster", + MongosProcesses: createMongosProcesses(5, "pretty", ""), + ConfigServerRs: configRs, + Shards: shards, + Finalizing: false, + } + + d.MergeShardedCluster(mergeOpts) + + // On first merge the redundant replica sets and processes are not removed, but 'draining' array is populated + shards = createSpecificNumberOfShards(3, "cluster") + mergeOpts = DeploymentShardedClusterMergeOptions{ + Name: "cluster", + MongosProcesses: createMongosProcesses(3, "pretty", ""), + ConfigServerRs: configRs, + Shards: shards, + Finalizing: false, + } + d.MergeShardedCluster(mergeOpts) + + expectedCluster := NewShardedCluster("cluster", configRs.Rs.Name(), shards) + expectedCluster.setDraining([]string{"cluster-3", "cluster-4"}) + checkShardedClusterCheckExtraReplicaSets(t, d, expectedCluster, shards, false) + + // On second merge the redundant rses and processes will be removed ('draining' array is gone as well) + shards = createSpecificNumberOfShards(3, "cluster") + mergeOpts = DeploymentShardedClusterMergeOptions{ + Name: "cluster", + MongosProcesses: createMongosProcesses(3, "pretty", ""), + ConfigServerRs: configRs, + Shards: shards, + Finalizing: true, + } + d.MergeShardedCluster(mergeOpts) + checkShardedClusterCheckExtraReplicaSets(t, d, NewShardedCluster("cluster", configRs.Rs.Name(), shards), shards, true) +} + +// TestMergeShardedCluster_MongosCountChanged checks the scenario of incrementing and decrementing the number of mongos +func TestMergeShardedCluster_MongosCountChanged(t *testing.T) { + d := NewDeployment() + + configRs := createConfigSrvRs("configSrv", false) + mergeOpts := DeploymentShardedClusterMergeOptions{ + Name: "cluster", + MongosProcesses: createMongosProcesses(3, "pretty", ""), + ConfigServerRs: configRs, + Shards: createShards("myShard"), + Finalizing: false, + } + + d.MergeShardedCluster(mergeOpts) + checkMongoSProcesses(t, d.getProcesses(), createMongosProcesses(3, "pretty", "cluster")) + + mergeOpts = DeploymentShardedClusterMergeOptions{ + Name: "cluster", + MongosProcesses: createMongosProcesses(4, "pretty", ""), + ConfigServerRs: configRs, + Shards: createShards("myShard"), + Finalizing: false, + } + + d.MergeShardedCluster(mergeOpts) + checkMongoSProcesses(t, d.getProcesses(), createMongosProcesses(4, "pretty", "cluster")) + + mergeOpts = DeploymentShardedClusterMergeOptions{ + Name: "cluster", + MongosProcesses: createMongosProcesses(2, "pretty", ""), + ConfigServerRs: configRs, + Shards: createShards("myShard"), + Finalizing: false, + } + d.MergeShardedCluster(mergeOpts) + checkMongoSProcesses(t, d.getProcesses(), createMongosProcesses(2, "pretty", "cluster")) +} + +// TestMergeShardedCluster_MongosCountChanged checks the scenario of incrementing and decrementing the number of replicas +// in config server +func TestMergeShardedCluster_ConfigSrvCountChanged(t *testing.T) { + d := NewDeployment() + + configRs := createConfigSrvRs("configSrv", false) + mergeOpts := DeploymentShardedClusterMergeOptions{ + Name: "cluster", + MongosProcesses: createMongosProcesses(3, "pretty", ""), + ConfigServerRs: configRs, + Shards: createShards("myShard"), + Finalizing: false, + } + d.MergeShardedCluster(mergeOpts) + checkReplicaSet(t, d, createConfigSrvRs("configSrv", true)) + + configRs = createConfigSrvRsCount(6, "configSrv", false) + mergeOpts = DeploymentShardedClusterMergeOptions{ + Name: "cluster", + MongosProcesses: createMongosProcesses(4, "pretty", ""), + ConfigServerRs: configRs, + Shards: createShards("myShard"), + Finalizing: false, + } + d.MergeShardedCluster(mergeOpts) + checkReplicaSet(t, d, createConfigSrvRsCount(6, "configSrv", true)) + + configRs = createConfigSrvRsCount(2, "configSrv", false) + + mergeOpts = DeploymentShardedClusterMergeOptions{ + Name: "cluster", + MongosProcesses: createMongosProcesses(4, "pretty", ""), + ConfigServerRs: configRs, + Shards: createShards("myShard"), + Finalizing: false, + } + d.MergeShardedCluster(mergeOpts) + checkReplicaSet(t, d, createConfigSrvRsCount(2, "configSrv", true)) +} + +func TestMergeShardedCluster_ScaleUpShardMergeFirstProcess(t *testing.T) { + d := NewDeployment() + + configRs := createConfigSrvRs("configSrv", false) + mergeOpts := DeploymentShardedClusterMergeOptions{ + Name: "cluster", + MongosProcesses: createMongosProcesses(3, "pretty", ""), + ConfigServerRs: configRs, + Shards: createShards("myShard"), + Finalizing: false, + } + + d.MergeShardedCluster(mergeOpts) + // creating other cluster to make sure no side effects occur + + mergeOpts = DeploymentShardedClusterMergeOptions{ + Name: "anotherCluster", + MongosProcesses: createMongosProcesses(3, "anotherMongos", ""), + ConfigServerRs: configRs, + Shards: createShards("anotherClusterSh"), + Finalizing: false, + } + + d.MergeShardedCluster(mergeOpts) + + // Emulating changes to current shards by OM + for _, s := range d.getShardedClusters()[0].shards() { + shardRs := d.getReplicaSetByName(s.rs()) + for _, m := range shardRs.Members() { + process := d.getProcessByName(m.Name()) + process.Args()["security"] = map[string]interface{}{"clusterAuthMode": "sendX509"} + } + } + + // Now we "scale up" mongods from 3 to 4 + shards := createShardsSpecificNumberOfMongods(4, "myShard") + mergeOpts = DeploymentShardedClusterMergeOptions{ + Name: "cluster", + MongosProcesses: createMongosProcesses(3, "pretty", ""), + ConfigServerRs: configRs, + Shards: shards, + Finalizing: false, + } + d.MergeShardedCluster(mergeOpts) + + expectedShards := createShardsSpecificNumberOfMongods(4, "myShard") + + for _, s := range expectedShards { + for _, p := range s.Processes { + p.Args()["security"] = map[string]interface{}{"clusterAuthMode": "sendX509"} + } + } + + expectedCluster := NewShardedCluster("cluster", configRs.Rs.Name(), expectedShards) + checkShardedCluster(t, d, expectedCluster, expectedShards) + + expectedAnotherCluster := NewShardedCluster("anotherCluster", configRs.Rs.Name(), createShards("anotherClusterSh")) + checkShardedCluster(t, d, expectedAnotherCluster, createShards("anotherClusterSh")) +} + +func TestMergeShardedCluster_ScaleUpMongosMergeFirstProcess(t *testing.T) { + d := NewDeployment() + + configRs := createConfigSrvRs("configSrv", false) + mergeOpts := DeploymentShardedClusterMergeOptions{ + Name: "cluster", + MongosProcesses: createMongosProcesses(3, "pretty", ""), + ConfigServerRs: configRs, + Shards: createShards("myShard"), + Finalizing: false, + } + + d.MergeShardedCluster(mergeOpts) + + mergeOpts.Name = "other" + mergeOpts.MongosProcesses = createMongosProcesses(3, "otherMongos", "") + mergeOpts.Shards = createShards("otherSh") + + d.MergeShardedCluster(mergeOpts) + + // Emulating changes to current mongoses by OM + for _, m := range d.getMongosProcessesNames("cluster") { + process := d.getProcessByName(m) + process.Args()["security"] = map[string]interface{}{"clusterAuthMode": "sendX509"} + } + + // Now we "scale up" mongoses from 3 to 5 + mongoses := createMongosProcesses(5, "pretty", "") + + mergeOpts = DeploymentShardedClusterMergeOptions{ + Name: "cluster", + MongosProcesses: mongoses, + ConfigServerRs: configRs, + Shards: createShards("myShard"), + Finalizing: false, + } + + d.MergeShardedCluster(mergeOpts) + + expectedMongoses := createMongosProcesses(5, "pretty", "cluster") + for _, p := range expectedMongoses { + p.Args()["security"] = map[string]interface{}{"clusterAuthMode": "sendX509"} + } + + checkMongoSProcesses(t, d.getProcesses(), expectedMongoses) + + // "other" mongoses stayed untouched + checkMongoSProcesses(t, d.getProcesses(), createMongosProcesses(3, "otherMongos", "other")) + + totalMongos := 0 + for _, p := range d.getProcesses() { + if p.ProcessType() == ProcessTypeMongos { + totalMongos++ + } + } + assert.Equal(t, 8, totalMongos) +} + +// TestRemoveShardedClusterByName checks that sharded cluster and all linked artifacts are removed - but existing objects +// should stay untouched +func TestRemoveShardedClusterByName(t *testing.T) { + d := NewDeployment() + configRs := createConfigSrvRs("configSrv", false) + mergeOpts := DeploymentShardedClusterMergeOptions{ + Name: "cluster", + MongosProcesses: createMongosProcesses(3, "pretty", ""), + ConfigServerRs: configRs, + Shards: createShards("myShard"), + Finalizing: false, + } + d.MergeShardedCluster(mergeOpts) + + configRs2 := createConfigSrvRs("otherConfigSrv", false) + mergeOpts = DeploymentShardedClusterMergeOptions{ + Name: "otherCluster", + MongosProcesses: createMongosProcesses(3, "ugly", ""), + ConfigServerRs: configRs2, + Shards: createShards("otherShard"), + Finalizing: false, + } + d.MergeShardedCluster(mergeOpts) + + mergeStandalone(d, createStandalone()) + + rs := mergeReplicaSet(d, "fooRs", createReplicaSetProcesses("fooRs")) + + d.RemoveShardedClusterByName("otherCluster", zap.S()) + + // First check that all other entities stay untouched + checkProcess(t, d, createStandalone()) + checkReplicaSet(t, d, rs) + checkMongoSProcesses(t, d.getProcesses(), createMongosProcesses(3, "pretty", "cluster")) + checkReplicaSet(t, d, createConfigSrvRs("configSrv", true)) + shards := createShards("myShard") + checkShardedCluster(t, d, NewShardedCluster("cluster", configRs.Rs.Name(), shards), shards) + + // Then check that the sharded cluster and all replica sets were removed + shards2 := createShards("otherShard") + checkShardedClusterRemoved(t, d, NewShardedCluster("otherCluster", configRs2.Rs.Name(), shards2), createConfigSrvRs("otherConfigSrv", false), shards2) +} diff --git a/controllers/om/fullreplicaset.go b/controllers/om/fullreplicaset.go new file mode 100644 index 000000000..a125fa5da --- /dev/null +++ b/controllers/om/fullreplicaset.go @@ -0,0 +1,92 @@ +package om + +import ( + "strconv" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/automationconfig" +) + +// ReplicaSetWithProcesses is a wrapper for replica set and processes that match to it +// The contract for class is that both processes and replica set are guaranteed to match to each other +// Note, that the type modifies the entities directly and doesn't create copies! (seems not a big deal for clients) +type ReplicaSetWithProcesses struct { + Rs ReplicaSet + Processes []Process +} + +// NewReplicaSetWithProcesses is the only correct function for creation ReplicaSetWithProcesses +func NewReplicaSetWithProcesses( + rs ReplicaSet, + processes []Process, + memberOptions []automationconfig.MemberOptions, +) ReplicaSetWithProcesses { + rs.clearMembers() + + for idx, p := range processes { + p.setReplicaSetName(rs.Name()) + var options automationconfig.MemberOptions + if len(memberOptions) > idx { + options = memberOptions[idx] + } + rs.addMember(p, "", options) + } + return ReplicaSetWithProcesses{rs, processes} +} + +// determineNextProcessIdStartingPoint returns the number which should be used as a starting +// point for generating new _ids. +func determineNextProcessIdStartingPoint(desiredProcesses []Process, existingProcessIds map[string]int) int { + // determine the next id, it has to be higher than any previous value + newId := 0 + for _, id := range existingProcessIds { + if id >= newId { + newId = id + 1 + } + } + return newId +} + +// NewMultiClusterReplicaSetWithProcesses Creates processes for a multi cluster deployment. +// This function ensures that new processes which are added never have an overlapping _id with any existing process. +// existing _ids are re-used, and when new processes are added, a new higher number is used. +func NewMultiClusterReplicaSetWithProcesses(rs ReplicaSet, processes []Process, memberOptions []automationconfig.MemberOptions, existingProcessIds map[string]int, connectivity *mdbv1.MongoDBConnectivity) ReplicaSetWithProcesses { + newId := determineNextProcessIdStartingPoint(processes, existingProcessIds) + rs.clearMembers() + for idx, p := range processes { + p.setReplicaSetName(rs.Name()) + var options automationconfig.MemberOptions + if len(memberOptions) > idx { + options = memberOptions[idx] + } + // ensure the process id is not changed if it already exists + if existingId, ok := existingProcessIds[p.Name()]; ok { + rs.addMember(p, strconv.Itoa(existingId), options) + } else { + // otherwise add a new id which is always incrementing + rs.addMember(p, strconv.Itoa(newId), options) + newId++ + } + } + fullRs := ReplicaSetWithProcesses{Rs: rs, Processes: processes} + if connectivity != nil { + fullRs.SetHorizons(connectivity.ReplicaSetHorizons) + } + return fullRs +} + +func (r ReplicaSetWithProcesses) GetProcessNames() []string { + processNames := make([]string, len(r.Processes)) + for i, p := range r.Processes { + processNames[i] = p.Name() + } + return processNames +} + +func (r ReplicaSetWithProcesses) SetHorizons(replicaSetHorizons []mdbv1.MongoDBHorizonConfig) { + if len(replicaSetHorizons) >= len(r.Rs.Members()) { + for i, m := range r.Rs.Members() { + m.setHorizonConfig(replicaSetHorizons[i]) + } + } +} diff --git a/controllers/om/fullreplicaset_test.go b/controllers/om/fullreplicaset_test.go new file mode 100644 index 000000000..2201cfa1a --- /dev/null +++ b/controllers/om/fullreplicaset_test.go @@ -0,0 +1,208 @@ +package om + +import ( + "testing" + + ac "github.com/mongodb/mongodb-kubernetes-operator/pkg/automationconfig" + "github.com/stretchr/testify/assert" + + "k8s.io/utils/pointer" +) + +func TestDetermineNextProcessIdStartingPoint(t *testing.T) { + + t.Run("New id should be higher than any other id", func(t *testing.T) { + desiredProcesses := []Process{ + { + "name": "p-0", + }, + { + "name": "p-1", + }, + { + "name": "p-2", + }, + { + "name": "p-3", + }, + } + + existingIds := map[string]int{ + "p-0": 0, + "p-1": 1, + "p-2": 2, + "p-3": 3, + } + + assert.Equal(t, 4, determineNextProcessIdStartingPoint(desiredProcesses, existingIds)) + }) + + t.Run("New id should be higher than other ids even if there are gaps in between", func(t *testing.T) { + desiredProcesses := []Process{ + { + "name": "p-0", + }, + { + "name": "p-1", + }, + { + "name": "p-2", + }, + } + + existingIds := map[string]int{ + "p-0": 0, + "p-1": 5, + "p-2": 3, + } + + assert.Equal(t, 6, determineNextProcessIdStartingPoint(desiredProcesses, existingIds)) + }) +} + +func TestNewMultiClusterReplicaSetWithProcesses(t *testing.T) { + tests := []struct { + name string + processes []Process + memberOptions []ac.MemberOptions + expected ReplicaSetWithProcesses + }{ + { + name: "Same number of processes and member options", + processes: []Process{ + { + "name": "p-0", + }, + { + "name": "p-1", + }, + }, + memberOptions: []ac.MemberOptions{ + { + Votes: pointer.Int(1), + Priority: pointer.String("1.3"), + }, + { + Votes: pointer.Int(0), + Priority: pointer.String("0.7"), + }, + }, + expected: ReplicaSetWithProcesses{ + Rs: ReplicaSet{"_id": "mdb-multi", "members": []ReplicaSetMember{ + ReplicaSetMember{"_id": "0", "host": "p-0", "priority": float32(1.3), "tags": map[string]string{}, "votes": 1}, + ReplicaSetMember{"_id": "1", "host": "p-1", "priority": float32(0.7), "tags": map[string]string{}, "votes": 0}}, + "protocolVersion": "1"}, + Processes: []Process{ + Process{"name": "p-0", "args2_6": map[string]interface{}{"replication": map[string]interface{}{"replSetName": "mdb-multi"}}}, + Process{"name": "p-1", "args2_6": map[string]interface{}{"replication": map[string]interface{}{"replSetName": "mdb-multi"}}}}}, + }, + { + name: "More member options than processes", + processes: []Process{ + { + "name": "p-0", + }, + { + "name": "p-1", + }, + }, + memberOptions: []ac.MemberOptions{ + { + Votes: pointer.Int(1), + Priority: pointer.String("1.3"), + }, + { + Votes: pointer.Int(0), + Priority: pointer.String("0.7"), + }, + { + Votes: pointer.Int(1), + Tags: map[string]string{ + "env": "dev", + }, + }, + }, + expected: ReplicaSetWithProcesses{ + Rs: ReplicaSet{"_id": "mdb-multi", "members": []ReplicaSetMember{ + ReplicaSetMember{"_id": "0", "host": "p-0", "priority": float32(1.3), "tags": map[string]string{}, "votes": 1}, + ReplicaSetMember{"_id": "1", "host": "p-1", "priority": float32(0.7), "tags": map[string]string{}, "votes": 0}}, + "protocolVersion": "1"}, + Processes: []Process{ + Process{"name": "p-0", "args2_6": map[string]interface{}{"replication": map[string]interface{}{"replSetName": "mdb-multi"}}}, + Process{"name": "p-1", "args2_6": map[string]interface{}{"replication": map[string]interface{}{"replSetName": "mdb-multi"}}}}}, + }, + { + name: "Less member options than processes", + processes: []Process{ + { + "name": "p-0", + }, + { + "name": "p-1", + }, + }, + memberOptions: []ac.MemberOptions{ + { + Votes: pointer.Int(1), + Priority: pointer.String("1.3"), + }, + }, + expected: ReplicaSetWithProcesses{ + Rs: ReplicaSet{"_id": "mdb-multi", "members": []ReplicaSetMember{ + ReplicaSetMember{"_id": "0", "host": "p-0", "priority": float32(1.3), "tags": map[string]string{}, "votes": 1}, + // Defaulting priority 1.0 and votes to 1 when no member options are present + ReplicaSetMember{"_id": "1", "host": "p-1", "priority": float32(1.0), "tags": map[string]string{}, "votes": 1}}, + "protocolVersion": "1"}, + Processes: []Process{ + Process{"name": "p-0", "args2_6": map[string]interface{}{"replication": map[string]interface{}{"replSetName": "mdb-multi"}}}, + Process{"name": "p-1", "args2_6": map[string]interface{}{"replication": map[string]interface{}{"replSetName": "mdb-multi"}}}}}, + }, + { + name: "No member options", + processes: []Process{ + { + "name": "p-0", + }, + { + "name": "p-1", + }, + }, + memberOptions: []ac.MemberOptions{}, + expected: ReplicaSetWithProcesses{ + Rs: ReplicaSet{"_id": "mdb-multi", "members": []ReplicaSetMember{ + // Defaulting priority 1.0 and votes to 1 when no member options are present + ReplicaSetMember{"_id": "0", "host": "p-0", "priority": float32(1.0), "tags": map[string]string{}, "votes": 1}, + // Defaulting priority 1.0 and votes to 1 when no member options are present + ReplicaSetMember{"_id": "1", "host": "p-1", "priority": float32(1.0), "tags": map[string]string{}, "votes": 1}}, + "protocolVersion": "1"}, + Processes: []Process{ + Process{"name": "p-0", "args2_6": map[string]interface{}{"replication": map[string]interface{}{"replSetName": "mdb-multi"}}}, + Process{"name": "p-1", "args2_6": map[string]interface{}{"replication": map[string]interface{}{"replSetName": "mdb-multi"}}}}}, + }, + { + name: "No processes", + processes: []Process{}, + memberOptions: []ac.MemberOptions{ + { + Votes: pointer.Int(1), + Priority: pointer.String("1.3"), + }, + { + Votes: pointer.Int(0), + Priority: pointer.String("0.7"), + }, + }, + expected: ReplicaSetWithProcesses{ + Rs: ReplicaSet{"_id": "mdb-multi", "members": []ReplicaSetMember{}, "protocolVersion": "1"}, + Processes: []Process{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := NewMultiClusterReplicaSetWithProcesses(NewReplicaSet("mdb-multi", "5.0.5"), tt.processes, tt.memberOptions, map[string]int{}, nil) + assert.Equal(t, tt.expected, actual) + }) + } +} diff --git a/controllers/om/group.go b/controllers/om/group.go new file mode 100644 index 000000000..c1ddff1bb --- /dev/null +++ b/controllers/om/group.go @@ -0,0 +1,25 @@ +package om + +// ProjectsResponse +type ProjectsResponse struct { + OMPaginated + Groups []*Project `json:"results"` +} + +func (o ProjectsResponse) Results() []interface{} { + // Lack of covariance in Go... :( + ans := make([]interface{}, len(o.Groups)) + for i, org := range o.Groups { + ans[i] = org + } + return ans +} + +// Project +type Project struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + OrgID string `json:"orgId,omitempty"` + Tags []string `json:"tags,omitempty"` + AgentAPIKey string `json:"agentApiKey,omitempty"` +} diff --git a/controllers/om/host/hosts.go b/controllers/om/host/hosts.go new file mode 100644 index 000000000..b421fd341 --- /dev/null +++ b/controllers/om/host/hosts.go @@ -0,0 +1,51 @@ +package host + +type Host struct { + Password string `json:"password"` + Username string `json:"username"` + Hostname string `json:"hostname"` + Port int `json:"port"` + AuthMechanismName string `json:"authMechanismName"` + Id string `json:"id"` +} + +type Result struct { + Results []Host `json:"results"` +} + +type Getter interface { + GetHosts() (*Result, error) +} + +type Adder interface { + AddHost(host Host) error +} + +type Updater interface { + UpdateHost(host Host) error +} + +type Remover interface { + RemoveHost(hostID string) error +} + +type GetRemover interface { + Getter + Remover +} + +type GetAdder interface { + Getter + Adder +} + +// Contains accepts a slice of Hosts and a host to look for +// it returns true if the host is present in the slice otherwise false +func Contains(hosts []Host, host Host) bool { + for _, h := range hosts { + if h.Hostname == host.Hostname { + return true + } + } + return false +} diff --git a/controllers/om/host/monitoring.go b/controllers/om/host/monitoring.go new file mode 100644 index 000000000..db04a422f --- /dev/null +++ b/controllers/om/host/monitoring.go @@ -0,0 +1,69 @@ +package host + +import ( + "errors" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "golang.org/x/xerrors" + + "go.uber.org/zap" +) + +// StopMonitoring will stop OM monitoring of hosts, which will then +// make OM stop displaying old hosts from Processes view. +// Note, that the method tries to delete as many hosts as possible and doesn't give up on errors, returns +// the last error instead +func StopMonitoring(getRemover GetRemover, hostnames []string, log *zap.SugaredLogger) error { + if len(hostnames) == 0 { + return nil + } + + hosts, err := getRemover.GetHosts() + if err != nil { + return err + } + errorHappened := false + for _, hostname := range hostnames { + found := false + for _, h := range hosts.Results { + if h.Hostname == hostname { + found = true + err = getRemover.RemoveHost(h.Id) + if err != nil { + log.Warnf("Failed to remove host %s from monitoring in Ops Manager: %s", h.Hostname, err) + errorHappened = true + } else { + log.Debugf("Removed the host %s from monitoring in Ops Manager", h.Hostname) + } + break + } + } + if !found { + log.Warnf("Unable to remove monitoring on host %s as it was not found", hostname) + } + } + + if errorHappened { + return errors.New("Failed to remove some hosts from monitoring in Ops manager") + } + return nil +} + +// stopMonitoringHosts removes monitoring for this list of hosts from Ops Manager. +func stopMonitoringHosts(getRemover GetRemover, hosts []string, log *zap.SugaredLogger) error { + if len(hosts) == 0 { + return nil + } + + if err := StopMonitoring(getRemover, hosts, log); err != nil { + return xerrors.Errorf("Failed to stop monitoring on hosts %s: %w", hosts, err) + } + + return nil +} + +// CalculateDiffAndStopMonitoringHosts checks hosts that are present in hostsBefore but not hostsAfter, and removes +// monitoring from them. +func CalculateDiffAndStopMonitoring(getRemover GetRemover, hostsBefore, hostsAfter []string, log *zap.SugaredLogger) error { + return stopMonitoringHosts(getRemover, util.FindLeftDifference(hostsBefore, hostsAfter), log) +} diff --git a/controllers/om/mockedomclient.go b/controllers/om/mockedomclient.go new file mode 100644 index 000000000..a7a45293f --- /dev/null +++ b/controllers/om/mockedomclient.go @@ -0,0 +1,740 @@ +package om + +import ( + "fmt" + "math/rand" + "reflect" + "runtime" + "strconv" + "testing" + "time" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/apierror" + "golang.org/x/xerrors" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/backup" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/host" + + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" + "github.com/10gen/ops-manager-kubernetes/pkg/util/versionutil" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/controlledfeature" + + "github.com/google/uuid" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "go.uber.org/zap" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" +) + +// ******************************************************************************************************************** +// Dev notes: +// * this is a mocked implementation of 'Connection' interface which mocks all communication with Ops Manager. It doesn't +// do anything sophisticated - just saves the state that OM is supposed to have after these invocations - for example +// the deployment pushed to it +// * The usual place to start from is the 'NewEmptyMockedOmConnection' method that pre-creates the group - convenient +// in most cases. Should work fine for the "full" reconciliation testing +// * The class tracks the functions called - some methods ('CheckOrderOfOperations', 'CheckOperationsDidntHappen' - more +// can be added) may help to check the communication happened +// * Any overriding of default behavior can be done via functions (e.g. 'CreateGroupFunc', 'UpdateGroupFunc') +// * To emulate the work of real OM it's possible to emulate the agents delay in "reaching" goal state. This can be +// configured using 'AgentsDelayCount' property +// * As Deployment has package access to most of its data to preserve encapsulation (processes, ssl etc) this class can +// be used as an access point to those fields for testing (see 'getProcesses' as an example) +// * Note that the variable 'CurrMockedConnection' is global (as Go tests don't allow to have setup/teardown hooks) +// The state is cleaned as soon as a new mocked api object is built (which usually occurs when the new reconciler is +// built) +// ******************************************************************************************************************** + +// Global variable for current OM connection object that was created by MongoDbController - just for tests +var CurrMockedConnection *MockedOmConnection + +const ( + TestGroupID = "abcd1234" + TestGroupName = "my-project" + TestOrgID = "xyz9876" + TestAgentKey = "qwerty9876" + TestURL = "http://mycompany.com:8080" + TestUser = "test@mycompany.com" + TestApiKey = "36lj245asg06s0h70245dstgft" +) + +type MockedOmConnection struct { + HTTPOmConnection + deployment Deployment + automationConfig *AutomationConfig + backupAgentConfig *BackupAgentConfig + monitoringAgentConfig *MonitoringAgentConfig + controlledFeature *controlledfeature.ControlledFeature + // hosts are used for both automation agents and monitoring endpoints. + // They are necessary for emulating "agents" are ready behavior as operator checks for hosts for agents to exist + hostResults *host.Result + + numRequestsSent int + AgentAPIKey string + OrganizationsWithGroups map[*Organization][]*Project + CreateGroupFunc func(group *Project) (*Project, error) + UpdateGroupFunc func(group *Project) (*Project, error) + BackupConfigs map[string]*backup.Config + BackupHostClusters map[string]*backup.HostCluster + UpdateBackupStatusFunc func(clusterId string, status backup.Status) error + AgentAuthMechanism string + SnapshotSchedules map[string]*backup.SnapshotSchedule + Hostnames []string + + // UpdateMonitoringAgentConfigFunc is delegated to if not nil when UpdateMonitoringAgentConfig is called + UpdateMonitoringAgentConfigFunc func(mac *MonitoringAgentConfig, log *zap.SugaredLogger) ([]byte, error) + // AgentsDelayCount is the number of loops to wait until the agents reach the goal + AgentsDelayCount int + // mocked client keeps track of all implemented functions called - uses reflection Func for this to enable type-safety + // and make function names rename easier + history []*runtime.Func +} + +var _ Connection = &MockedOmConnection{} + +// NewEmptyMockedConnection is the standard function for creating mocked connections that is usually used for testing +// "full cycle" mocked controller. It has group created already, but doesn't have the deployment. Also it "survives" +// recreations (as this is what we do in 'ReconcileCommonController.prepareConnection') +func NewEmptyMockedOmConnection(ctx *OMContext) Connection { + conn := NewEmptyMockedOmConnectionNoGroup(ctx) + // by default each connection just "reuses" "already created" group with agent keys existing + conn.(*MockedOmConnection).OrganizationsWithGroups = map[*Organization][]*Project{ + {ID: TestOrgID, Name: TestGroupName}: {{ + Name: TestGroupName, + ID: TestGroupID, + Tags: []string{util.OmGroupExternallyManagedTag}, + AgentAPIKey: TestAgentKey, + OrgID: TestOrgID, + }}, + } + + return conn +} + +// NewEmptyMockedOmConnectionWithDelay is the function that builds the mocked connection with some "delay" for agents +// to reach goal state, apart of this it's the same as 'NewEmptyMockedOmConnection' +func NewEmptyMockedOmConnectionWithDelay(ctx *OMContext) Connection { + conn := NewEmptyMockedOmConnection(ctx) + conn.(*MockedOmConnection).AgentsDelayCount = 1 + return conn +} + +// NewMockedConnection is the simplified connection wrapping some deployment that already exists. Should be used for +// partial functionality (not the "full cycle" controller), for example read-update operation for the deployment +func NewMockedOmConnection(d Deployment) *MockedOmConnection { + connection := MockedOmConnection{deployment: d} + connection.hostResults = buildHostsFromDeployment(d) + connection.BackupConfigs = make(map[string]*backup.Config) + connection.BackupHostClusters = make(map[string]*backup.HostCluster) + connection.SnapshotSchedules = make(map[string]*backup.SnapshotSchedule) + // By default we don't wait for agents to reach goal + connection.AgentsDelayCount = 0 + // We use a simplified version of context as this is the only thing needed to get lock for the update + connection.context = &OMContext{GroupName: TestGroupName, OrgID: TestOrgID, GroupID: TestGroupID} + return &connection +} + +// NewEmptyMockedOmConnectionWithAutomationConfigChanges returns a Connect instance that has had +// changes applied to the underlying AutomationConfig. This is to update the state of the AutomationConfig +// before the connection is used. +func NewEmptyMockedOmConnectionWithAutomationConfigChanges(ctx *OMContext, acFunc func(ac *AutomationConfig)) Connection { + connection := NewEmptyMockedOmConnection(ctx) + _ = connection.ReadUpdateAutomationConfig(func(ac *AutomationConfig) error { + acFunc(ac) + return nil + }, nil) + return connection +} + +// NewEmptyMockedConnection is the standard function for creating mocked connections that is usually used for testing +// "full cycle" mocked controller. It doesn't have the group created. +func NewEmptyMockedOmConnectionNoGroup(ctx *OMContext) Connection { + var connection *MockedOmConnection + // That's how we can "survive" multiple calls to this function: so we can create groups or add/delete entities + // Note, that the global connection variable is cleaned before each test (see kubeapi_test.newMockedKubeApi) + if CurrMockedConnection != nil { + connection = CurrMockedConnection + } else { + connection = NewMockedOmConnection(nil) + connection.OrganizationsWithGroups = make(map[*Organization][]*Project) + } + + connection.HTTPOmConnection = HTTPOmConnection{ + context: ctx, + } + + CurrMockedConnection = connection + + return connection +} + +func (oc *MockedOmConnection) UpdateDeployment(d Deployment) ([]byte, error) { + oc.addToHistory(reflect.ValueOf(oc.UpdateDeployment)) + oc.numRequestsSent++ + oc.deployment = d + return nil, nil +} + +func (oc *MockedOmConnection) ReadDeployment() (Deployment, error) { + oc.addToHistory(reflect.ValueOf(oc.ReadDeployment)) + if oc.deployment == nil { + return NewDeployment(), nil + } + return oc.deployment, nil +} +func (oc *MockedOmConnection) ReadUpdateDeployment(depFunc func(Deployment) error, log *zap.SugaredLogger) error { + oc.addToHistory(reflect.ValueOf(oc.ReadUpdateDeployment)) + if oc.deployment == nil { + oc.deployment = NewDeployment() + } + depFunc(oc.deployment) + oc.numRequestsSent++ + return nil +} + +func (oc *MockedOmConnection) ReadUpdateMonitoringAgentConfig(matFunc func(*MonitoringAgentConfig) error, log *zap.SugaredLogger) error { + oc.addToHistory(reflect.ValueOf(oc.ReadUpdateMonitoringAgentConfig)) + if oc.monitoringAgentConfig == nil { + oc.monitoringAgentConfig = &MonitoringAgentConfig{MonitoringAgentTemplate: &MonitoringAgentTemplate{}} + } + + err := matFunc(oc.monitoringAgentConfig) + if err != nil { + return err + } + _, err = oc.UpdateMonitoringAgentConfig(oc.monitoringAgentConfig, log) + return err +} + +func (oc *MockedOmConnection) UpdateAutomationConfig(ac *AutomationConfig, log *zap.SugaredLogger) error { + oc.addToHistory(reflect.ValueOf(oc.UpdateAutomationConfig)) + oc.deployment = ac.Deployment + oc.automationConfig = ac + return nil +} + +func (oc *MockedOmConnection) ReadAutomationConfig() (*AutomationConfig, error) { + oc.addToHistory(reflect.ValueOf(oc.ReadAutomationConfig)) + if oc.automationConfig == nil { + if oc.deployment == nil { + oc.deployment = NewDeployment() + } + oc.automationConfig = NewAutomationConfig(oc.deployment) + } + return oc.automationConfig, nil +} + +func (oc *MockedOmConnection) ReadUpdateAutomationConfig(modifyACFunc func(ac *AutomationConfig) error, log *zap.SugaredLogger) error { + oc.addToHistory(reflect.ValueOf(oc.ReadUpdateAutomationConfig)) + if oc.automationConfig == nil { + if oc.deployment == nil { + oc.deployment = NewDeployment() + } + oc.automationConfig = NewAutomationConfig(oc.deployment) + } + + // when we update the mocked automation config, update the corresponding deployment + err := modifyACFunc(oc.automationConfig) + + // mock the change of auto auth mechanism that is done based on the provided autoAuthMechanisms + updateAutoAuthMechanism(oc.automationConfig) + + _ = oc.automationConfig.Apply() + oc.deployment = oc.automationConfig.Deployment + return err +} + +func (oc *MockedOmConnection) AddHost(host host.Host) error { + oc.hostResults.Results = append(oc.hostResults.Results, host) + return nil +} + +func (oc *MockedOmConnection) UpdateHost(host host.Host) error { + // assume the host in question exists + for idx := range oc.hostResults.Results { + if oc.hostResults.Results[idx].Hostname == host.Hostname { + oc.hostResults.Results[idx] = host + } + } + return nil +} + +func (oc *MockedOmConnection) MarkProjectAsBackingDatabase(_ BackingDatabaseType) error { + return nil +} + +func (oc *MockedOmConnection) UpgradeAgentsToLatest() (string, error) { + oc.addToHistory(reflect.ValueOf(oc.UpgradeAgentsToLatest)) + return "new-version", nil +} + +func (oc *MockedOmConnection) ReadBackupAgentConfig() (*BackupAgentConfig, error) { + oc.addToHistory(reflect.ValueOf(oc.ReadBackupAgentConfig)) + if oc.backupAgentConfig == nil { + oc.backupAgentConfig = &BackupAgentConfig{BackupAgentTemplate: &BackupAgentTemplate{}} + } + return oc.backupAgentConfig, nil +} + +func (oc *MockedOmConnection) UpdateBackupAgentConfig(bac *BackupAgentConfig, log *zap.SugaredLogger) ([]byte, error) { + oc.addToHistory(reflect.ValueOf(oc.UpdateBackupAgentConfig)) + oc.backupAgentConfig = bac + return nil, nil +} + +func (oc *MockedOmConnection) ReadUpdateBackupAgentConfig(bacFunc func(*BackupAgentConfig) error, log *zap.SugaredLogger) error { + oc.addToHistory(reflect.ValueOf(oc.ReadUpdateBackupAgentConfig)) + if oc.backupAgentConfig == nil { + oc.backupAgentConfig = &BackupAgentConfig{BackupAgentTemplate: &BackupAgentTemplate{}} + } + return bacFunc(oc.backupAgentConfig) +} + +func (oc *MockedOmConnection) ReadMonitoringAgentConfig() (*MonitoringAgentConfig, error) { + oc.addToHistory(reflect.ValueOf(oc.ReadMonitoringAgentConfig)) + if oc.monitoringAgentConfig == nil { + oc.monitoringAgentConfig = &MonitoringAgentConfig{MonitoringAgentTemplate: &MonitoringAgentTemplate{}} + } + return oc.monitoringAgentConfig, nil +} + +func (oc *MockedOmConnection) UpdateMonitoringAgentConfig(mac *MonitoringAgentConfig, log *zap.SugaredLogger) ([]byte, error) { + oc.addToHistory(reflect.ValueOf(oc.UpdateMonitoringAgentConfig)) + if oc.UpdateMonitoringAgentConfigFunc != nil { + return oc.UpdateMonitoringAgentConfigFunc(mac, log) + } + oc.monitoringAgentConfig = mac + return nil, nil +} + +func (oc *MockedOmConnection) GenerateAgentKey() (string, error) { + oc.addToHistory(reflect.ValueOf(oc.GenerateAgentKey)) + + return oc.AgentAPIKey, nil +} + +func (oc *MockedOmConnection) ReadAutomationStatus() (*AutomationStatus, error) { + oc.addToHistory(reflect.ValueOf(oc.ReadAutomationStatus)) + + if oc.AgentsDelayCount <= 0 { + // Emulating "agents reached goal state": returning the proper status for all the + // processes in the deployment + return oc.buildAutomationStatusFromDeployment(oc.deployment, true), nil + } + oc.AgentsDelayCount-- + + return oc.buildAutomationStatusFromDeployment(oc.deployment, false), nil +} +func (oc *MockedOmConnection) ReadAutomationAgents(pageNum int) (Paginated, error) { + oc.addToHistory(reflect.ValueOf(oc.ReadAutomationAgents)) + + results := make([]AgentStatus, 0) + for _, r := range oc.hostResults.Results { + results = append(results, + AgentStatus{Hostname: r.Hostname, LastConf: time.Now().Add(time.Second * -1).Format(time.RFC3339)}) + } + // todo extend this for real testing + return automationAgentStatusResponse{AutomationAgents: results}, nil +} +func (oc *MockedOmConnection) GetHosts() (*host.Result, error) { + oc.addToHistory(reflect.ValueOf(oc.GetHosts)) + return oc.hostResults, nil +} +func (oc *MockedOmConnection) RemoveHost(hostID string) error { + oc.addToHistory(reflect.ValueOf(oc.RemoveHost)) + toKeep := make([]host.Host, 0) + for _, v := range oc.hostResults.Results { + if v.Id != hostID { + toKeep = append(toKeep, v) + } + } + oc.hostResults = &host.Result{Results: toKeep} + return nil +} + +func (oc *MockedOmConnection) ReadOrganizationsByName(name string) ([]*Organization, error) { + oc.addToHistory(reflect.ValueOf(oc.ReadOrganizationsByName)) + allOrgs := make([]*Organization, 0) + for k := range oc.OrganizationsWithGroups { + if k.Name == name { + allOrgs = append(allOrgs, k) + } + } + if len(allOrgs) == 0 { + return nil, apierror.NewErrorWithCode(apierror.OrganizationNotFound) + } + return allOrgs, nil +} + +func (oc *MockedOmConnection) ReadOrganizations(page int) (Paginated, error) { + oc.addToHistory(reflect.ValueOf(oc.ReadOrganizations)) + // We don't set Next field - so there should be no pagination + allOrgs := make([]*Organization, 0) + for k := range oc.OrganizationsWithGroups { + allOrgs = append(allOrgs, k) + } + response := OrganizationsResponse{Organizations: allOrgs, OMPaginated: OMPaginated{TotalCount: len(oc.OrganizationsWithGroups)}} + return &response, nil +} + +func (oc *MockedOmConnection) ReadOrganization(orgID string) (*Organization, error) { + oc.addToHistory(reflect.ValueOf(oc.ReadOrganization)) + return oc.findOrganization(orgID) +} + +func (oc *MockedOmConnection) ReadProjectsInOrganizationByName(orgID string, name string) ([]*Project, error) { + oc.addToHistory(reflect.ValueOf(oc.ReadProjectsInOrganizationByName)) + org, err := oc.findOrganization(orgID) + if err != nil { + return nil, err + } + projects := make([]*Project, 0) + for _, p := range oc.OrganizationsWithGroups[org] { + if p.Name == name { + projects = append(projects, p) + } + } + return projects, nil +} + +func (oc *MockedOmConnection) ReadProjectsInOrganization(orgID string, page int) (Paginated, error) { + oc.addToHistory(reflect.ValueOf(oc.ReadProjectsInOrganization)) + org, err := oc.findOrganization(orgID) + if err != nil { + return nil, err + } + response := &ProjectsResponse{Groups: oc.OrganizationsWithGroups[org], OMPaginated: OMPaginated{TotalCount: len(oc.OrganizationsWithGroups[org])}} + return response, nil +} + +func (oc *MockedOmConnection) CreateProject(project *Project) (*Project, error) { + oc.addToHistory(reflect.ValueOf(oc.CreateProject)) + if oc.CreateGroupFunc != nil { + return oc.CreateGroupFunc(project) + } + project.ID = TestGroupID + + // We emulate the behavior of Ops Manager: we create the organization with random id and the name matching the project + organization := &Organization{ID: strconv.Itoa(rand.Int()), Name: project.Name} + if _, exists := oc.OrganizationsWithGroups[organization]; !exists { + oc.OrganizationsWithGroups[organization] = make([]*Project, 0) + } + project.OrgID = organization.ID + oc.OrganizationsWithGroups[organization] = append(oc.OrganizationsWithGroups[organization], project) + return project, nil +} +func (oc *MockedOmConnection) UpdateProject(project *Project) (*Project, error) { + oc.addToHistory(reflect.ValueOf(oc.UpdateProject)) + if oc.UpdateGroupFunc != nil { + return oc.UpdateGroupFunc(project) + } + org, err := oc.findOrganization(project.OrgID) + if err != nil { + return nil, err + } + for _, g := range oc.OrganizationsWithGroups[org] { + if g.Name == project.Name { + *g = *project + return project, nil + } + } + return nil, xerrors.Errorf("failed to find project") +} + +func (oc *MockedOmConnection) UpdateBackupConfig(config *backup.Config) (*backup.Config, error) { + oc.addToHistory(reflect.ValueOf(oc.UpdateBackupConfig)) + oc.BackupConfigs[config.ClusterId] = config + return config, nil +} + +func (oc *MockedOmConnection) ReadBackupConfigs() (*backup.ConfigsResponse, error) { + oc.addToHistory(reflect.ValueOf(oc.ReadBackupConfigs)) + + values := make([]*backup.Config, 0, len(oc.BackupConfigs)) + for _, v := range oc.BackupConfigs { + values = append(values, v) + } + return &backup.ConfigsResponse{Configs: values}, nil +} +func (oc *MockedOmConnection) ReadBackupConfig(clusterId string) (*backup.Config, error) { + oc.addToHistory(reflect.ValueOf(oc.ReadBackupConfig)) + + if config, ok := oc.BackupConfigs[clusterId]; ok { + return config, nil + } + return nil, apierror.New(errors.New("Failed to find backup config")) +} + +func (oc *MockedOmConnection) ReadHostCluster(clusterId string) (*backup.HostCluster, error) { + oc.addToHistory(reflect.ValueOf(oc.ReadHostCluster)) + + if hostCluster, ok := oc.BackupHostClusters[clusterId]; ok { + return hostCluster, nil + } + return nil, apierror.New(errors.New("Failed to find host cluster")) +} + +func (oc *MockedOmConnection) UpdateBackupStatus(clusterId string, newStatus backup.Status) error { + oc.addToHistory(reflect.ValueOf(oc.UpdateBackupStatus)) + + if oc.UpdateBackupStatusFunc != nil { + return oc.UpdateBackupStatusFunc(clusterId, newStatus) + } + + oc.doUpdateBackupStatus(clusterId, newStatus) + return nil +} + +func (oc *MockedOmConnection) UpdateControlledFeature(cf *controlledfeature.ControlledFeature) error { + oc.controlledFeature = cf + return nil +} + +func (oc *MockedOmConnection) GetControlledFeature() (*controlledfeature.ControlledFeature, error) { + if oc.controlledFeature == nil { + oc.controlledFeature = &controlledfeature.ControlledFeature{} + } + return oc.controlledFeature, nil +} + +func (oc *MockedOmConnection) GetAgentAuthMode() (string, error) { + return oc.AgentAuthMechanism, nil +} + +func (oc *MockedOmConnection) ReadSnapshotSchedule(clusterID string) (*backup.SnapshotSchedule, error) { + if snapshotSchedule, ok := oc.SnapshotSchedules[clusterID]; ok { + return snapshotSchedule, nil + } + return nil, apierror.New(errors.New("Failed to find snapshot schedule")) +} + +func (oc *MockedOmConnection) UpdateSnapshotSchedule(clusterID string, snapshotSchedule *backup.SnapshotSchedule) error { + oc.addToHistory(reflect.ValueOf(oc.UpdateSnapshotSchedule)) + oc.SnapshotSchedules[clusterID] = snapshotSchedule + return nil +} + +// ************* These are native methods of Mocked client (not implementation of OmConnection) + +func (oc *MockedOmConnection) CheckMonitoredHostsRemoved(t *testing.T, removedHosts []string) { + for _, v := range oc.hostResults.Results { + for _, e := range removedHosts { + assert.NotEqual(t, e, v.Hostname, "Host %s is expected to be removed from monitored", e) + } + } +} + +func (oc *MockedOmConnection) doUpdateBackupStatus(clusterID string, newStatus backup.Status) { + if value, ok := oc.BackupConfigs[clusterID]; ok { + if newStatus == backup.Terminating { + value.Status = backup.Inactive + } else { + value.Status = newStatus + } + } +} + +func (oc *MockedOmConnection) GetProcesses() []Process { + return oc.deployment.getProcesses() +} + +func (oc *MockedOmConnection) GetTLS() map[string]interface{} { + return oc.deployment.getTLS() +} + +func (oc *MockedOmConnection) CheckNumberOfUpdateRequests(t *testing.T, expected int) { + assert.Equal(t, expected, oc.numRequestsSent) +} + +func (oc *MockedOmConnection) CheckDeployment(t *testing.T, expected Deployment, ignoreFields ...string) { + for key := range expected { + if !stringutil.Contains(ignoreFields, key) { + assert.Equal(t, expected[key], oc.deployment[key]) + } + } + +} + +func (oc *MockedOmConnection) CheckResourcesDeleted(t *testing.T) { + oc.CheckResourcesAndBackupDeleted(t, "") +} + +// CheckResourcesDeleted verifies the results of "delete" operations in OM: the deployment and monitoring must be empty, +// backup - inactive (note, that in real life backup config will disappear together with monitoring hosts, but we +// ignore this for the sake of testing) +func (oc *MockedOmConnection) CheckResourcesAndBackupDeleted(t *testing.T, resourceName string) { + // This can be improved for some more complicated scenarios when we have different resources in parallel - so far + // just checking if deployment + assert.Empty(t, oc.deployment.getProcesses()) + assert.Empty(t, oc.deployment.getReplicaSets()) + assert.Empty(t, oc.deployment.getShardedClusters()) + assert.Empty(t, oc.deployment.getMonitoringVersions()) + assert.Empty(t, oc.deployment.getBackupVersions()) + assert.Empty(t, oc.hostResults.Results) + + if resourceName != "" { + assert.NotEmpty(t, oc.BackupHostClusters) + + found := false + for k, v := range oc.BackupHostClusters { + if v.ClusterName == resourceName { + assert.Equal(t, backup.Inactive, oc.BackupConfigs[k].Status) + found = true + } + } + assert.True(t, found) + + oc.CheckOrderOfOperations(t, reflect.ValueOf(oc.ReadBackupConfigs), reflect.ValueOf(oc.ReadHostCluster), + reflect.ValueOf(oc.UpdateBackupStatus)) + } +} + +func (oc *MockedOmConnection) CleanHistory() { + oc.history = make([]*runtime.Func, 0) +} + +// CheckOrderOfOperations verifies the mocked client operations were called in specified order +func (oc *MockedOmConnection) CheckOrderOfOperations(t *testing.T, value ...reflect.Value) { + j := 0 + matched := "" + for _, h := range oc.history { + zap.S().Info(h.Name()) + if h.Name() == runtime.FuncForPC(value[j].Pointer()).Name() { + matched += h.Name() + " " + j++ + } + if j == len(value) { + break + } + } + assert.Equal(t, len(value), j, "Only %d of %d expected operations happened in expected order (%s)", j, len(value), matched) +} + +func (oc *MockedOmConnection) CheckOperationsDidntHappen(t *testing.T, value ...reflect.Value) { + for _, h := range oc.history { + for _, o := range value { + assert.NotEqual(t, h.Name(), runtime.FuncForPC(o.Pointer()).Name(), "Operation %v is not expected to happen", h.Name()) + } + } +} + +// this is internal method only for testing, used by kubernetes mocked client +func (oc *MockedOmConnection) AddHosts(hostnames []string) { + for i, p := range hostnames { + oc.hostResults.Results = append(oc.hostResults.Results, host.Host{Id: strconv.Itoa(i), Hostname: p}) + } +} + +func (oc *MockedOmConnection) EnableBackup(resourceName string, resourceType backup.MongoDbResourceType, uuidStr string) { + if resourceType == backup.ReplicaSetType { + config := backup.Config{ClusterId: uuidStr, Status: backup.Started} + cluster := backup.HostCluster{TypeName: "REPLICA_SET", ClusterName: resourceName, ReplicaSetName: resourceName} + oc.BackupConfigs[uuidStr] = &config + oc.BackupHostClusters[uuidStr] = &cluster + } else { + config := backup.Config{ClusterId: uuidStr, Status: backup.Started} + cluster := backup.HostCluster{TypeName: "SHARDED_REPLICA_SET", ClusterName: resourceName, ShardName: resourceName} + oc.BackupConfigs[uuidStr] = &config + oc.BackupHostClusters[uuidStr] = &cluster + + // adding some host clusters for one shard and one config server - we don't care about relevance as they are + // expected top be ignored by Operator + + configUUID := uuid.New().String() + config1 := backup.Config{ClusterId: configUUID, Status: backup.Inactive} + cluster1 := backup.HostCluster{TypeName: "REPLICA_SET", ClusterName: resourceName, ShardName: resourceName + "-0"} + oc.BackupConfigs[configUUID] = &config1 + oc.BackupHostClusters[configUUID] = &cluster1 + + config2UUID := uuid.New().String() + config2 := backup.Config{ClusterId: config2UUID, Status: backup.Inactive} + cluster2 := backup.HostCluster{TypeName: "REPLICA_SET", ClusterName: resourceName, ShardName: resourceName + "-config-rs-0"} + oc.BackupConfigs[config2UUID] = &config2 + oc.BackupHostClusters[config2UUID] = &cluster2 + } +} + +func (oc *MockedOmConnection) addToHistory(value reflect.Value) { + oc.history = append(oc.history, runtime.FuncForPC(value.Pointer())) +} + +func buildHostsFromDeployment(d Deployment) *host.Result { + hosts := make([]host.Host, 0) + if d != nil { + for i, p := range d.getProcesses() { + hosts = append(hosts, host.Host{Id: strconv.Itoa(i), Hostname: p.HostName()}) + } + } + return &host.Result{Results: hosts} +} + +func (oc *MockedOmConnection) buildAutomationStatusFromDeployment(d Deployment, reached bool) *AutomationStatus { + // edge case: if there are no processes - we think that + processStatuses := make([]ProcessStatus, 0) + if d != nil { + for _, p := range d.getProcesses() { + if reached { + processStatuses = append(processStatuses, ProcessStatus{Name: p.Name(), LastGoalVersionAchieved: 1}) + } else { + processStatuses = append(processStatuses, ProcessStatus{Name: p.Name(), LastGoalVersionAchieved: 0}) + } + } + } + return &AutomationStatus{GoalVersion: 1, Processes: processStatuses} +} + +func (oc *MockedOmConnection) CheckGroupInOrganization(t *testing.T, orgName, groupName string) { + for k, v := range oc.OrganizationsWithGroups { + if k.Name == orgName { + for _, g := range v { + if g.Name == groupName { + return + } + } + } + } + assert.Fail(t, fmt.Sprintf("Project %s not found in organization %s", groupName, orgName)) +} + +func (oc *MockedOmConnection) FindGroup(groupName string) *Project { + for _, v := range oc.OrganizationsWithGroups { + for _, g := range v { + if g.Name == groupName { + return g + } + } + } + return nil +} + +func (oc *MockedOmConnection) findOrganization(orgId string) (*Organization, error) { + for k := range oc.OrganizationsWithGroups { + if k.ID == orgId { + return k, nil + } + } + return nil, apierror.New(xerrors.Errorf("Organization with id %s not found", orgId)) +} + +func (oc *MockedOmConnection) OpsManagerVersion() versionutil.OpsManagerVersion { + if oc.context.Version.VersionString != "" { + return oc.context.Version + } + return versionutil.OpsManagerVersion{VersionString: "5.0.0"} +} + +// updateAutoAuthMechanism simulates the changes made by Ops Manager and the agents in deciding which +// mechanism will be specified as the "autoAuthMechanisms" +func updateAutoAuthMechanism(ac *AutomationConfig) { + mechanisms := ac.Auth.AutoAuthMechanisms + if stringutil.Contains(mechanisms, "SCRAM-SHA-256") { + ac.Auth.AutoAuthMechanism = "SCRAM-SHA-256" + } else if stringutil.Contains(mechanisms, "MONGODB-CR") { + ac.Auth.AutoAuthMechanism = "MONGODB-CR" + } else if stringutil.Contains(mechanisms, "MONGODB-X509") { + ac.Auth.AutoAuthMechanism = "MONGODB-X509" + } +} diff --git a/controllers/om/monitoring_agent_config.go b/controllers/om/monitoring_agent_config.go new file mode 100644 index 000000000..3be2cf743 --- /dev/null +++ b/controllers/om/monitoring_agent_config.go @@ -0,0 +1,94 @@ +package om + +import ( + "encoding/json" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" +) + +type MonitoringAgentConfig struct { + MonitoringAgentTemplate *MonitoringAgentTemplate + BackingMap map[string]interface{} +} + +type MonitoringAgentTemplate struct { + Username string `json:"username,omitempty"` + Password string `json:"password"` + SSLPemKeyFile string `json:"sslPEMKeyFile,omitempty"` + LdapGroupDN string `json:"ldapGroupDN"` +} + +func (m *MonitoringAgentConfig) Apply() error { + merged, err := util.MergeWith(m.MonitoringAgentTemplate, m.BackingMap, &util.AutomationConfigTransformer{}) + if err != nil { + return err + } + m.BackingMap = merged + return nil +} + +func (m *MonitoringAgentConfig) SetAgentUserName(MonitoringAgentSubject string) { + m.MonitoringAgentTemplate.Username = MonitoringAgentSubject +} + +func (m *MonitoringAgentConfig) UnsetAgentUsername() { + m.MonitoringAgentTemplate.Username = util.MergoDelete +} + +func (m *MonitoringAgentConfig) SetAgentPassword(pwd string) { + m.MonitoringAgentTemplate.Password = pwd +} + +func (m *MonitoringAgentConfig) UnsetAgentPassword() { + m.MonitoringAgentTemplate.Password = util.MergoDelete +} + +func (m *MonitoringAgentConfig) EnableX509Authentication(MonitoringAgentSubject string) { + m.MonitoringAgentTemplate.SSLPemKeyFile = util.AutomationAgentPemFilePath + m.SetAgentUserName(MonitoringAgentSubject) +} + +func (m *MonitoringAgentConfig) DisableX509Authentication() { + m.MonitoringAgentTemplate.SSLPemKeyFile = util.MergoDelete + m.UnsetAgentUsername() +} + +func (m *MonitoringAgentConfig) EnableLdapAuthentication(monitoringAgentSubject string, monitoringAgentPwd string) { + m.SetAgentUserName(monitoringAgentSubject) + m.SetAgentPassword(monitoringAgentPwd) +} + +func (m *MonitoringAgentConfig) DisableLdapAuthentication() { + m.UnsetAgentUsername() + m.UnsetAgentPassword() + m.UnsetLdapGroupDN() +} + +func (m *MonitoringAgentConfig) SetLdapGroupDN(ldapGroupDn string) { + m.MonitoringAgentTemplate.LdapGroupDN = ldapGroupDn +} + +func (m *MonitoringAgentConfig) UnsetLdapGroupDN() { + m.MonitoringAgentTemplate.LdapGroupDN = util.MergoDelete +} + +// BuildMonitoringAgentConfigFromBytes +func BuildMonitoringAgentConfigFromBytes(jsonBytes []byte) (*MonitoringAgentConfig, error) { + fullMap := make(map[string]interface{}) + if err := json.Unmarshal(jsonBytes, &fullMap); err != nil { + return nil, err + } + + config := &MonitoringAgentConfig{BackingMap: fullMap} + template := &MonitoringAgentTemplate{} + if username, ok := fullMap["username"].(string); ok { + template.Username = username + } + + if sslPemKeyfile, ok := fullMap["sslPEMKeyFile"].(string); ok { + template.SSLPemKeyFile = sslPemKeyfile + } + + config.MonitoringAgentTemplate = template + return config, nil +} diff --git a/controllers/om/monitoring_agent_test.go b/controllers/om/monitoring_agent_test.go new file mode 100644 index 000000000..3d2ef6ed0 --- /dev/null +++ b/controllers/om/monitoring_agent_test.go @@ -0,0 +1,50 @@ +package om + +import ( + "testing" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/stretchr/testify/assert" +) + +var testMonitoringConfig = *getTestMonitoringConfig() + +func getTestMonitoringConfig() *MonitoringAgentConfig { + a, _ := BuildMonitoringAgentConfigFromBytes(loadBytesFromTestData("monitoring_config.json")) + return a +} + +func TestMonitoringAgentConfigApply(t *testing.T) { + config := getTestMonitoringConfig() + config.MonitoringAgentTemplate.Username = "my-user-name" + config.MonitoringAgentTemplate.SSLPemKeyFile = util.MergoDelete + + config.Apply() + + modified := config.BackingMap + assert.Equal(t, "my-user-name", modified["username"], "modified values should be reflected in the map") + assert.NotContains(t, modified, "sslPEMKeyFile", "final map should not have keys with empty values") +} + +func TestFieldsAreAddedToMonitoringConfig(t *testing.T) { + config := getTestMonitoringConfig() + config.MonitoringAgentTemplate.SSLPemKeyFile = "my-pem-file" + config.MonitoringAgentTemplate.Username = "my-user-name" + + config.Apply() + + modified := config.BackingMap + assert.Equal(t, modified["sslPEMKeyFile"], "my-pem-file") + assert.Equal(t, modified["username"], "my-user-name") +} + +func TestFieldsAreNotRemovedWhenUpdatingMonitoringConfig(t *testing.T) { + config := getTestMonitoringConfig() + config.MonitoringAgentTemplate.SSLPemKeyFile = "my-pem-file" + config.MonitoringAgentTemplate.Username = "my-user-name" + + config.Apply() + + assert.Equal(t, config.BackingMap["logPath"], testMonitoringConfig.BackingMap["logPath"]) + assert.Equal(t, config.BackingMap["logPathWindows"], testMonitoringConfig.BackingMap["logPathWindows"]) +} diff --git a/controllers/om/omclient.go b/controllers/om/omclient.go new file mode 100644 index 000000000..23e37efcd --- /dev/null +++ b/controllers/om/omclient.go @@ -0,0 +1,912 @@ +package om + +import ( + "encoding/json" + "fmt" + "net/url" + "reflect" + "strings" + "sync" + + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" + diff "github.com/r3labs/diff/v3" + + "k8s.io/utils/pointer" + + apierror "github.com/10gen/ops-manager-kubernetes/controllers/om/apierror" + "github.com/10gen/ops-manager-kubernetes/controllers/om/backup" + + "github.com/10gen/ops-manager-kubernetes/pkg/util/versionutil" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/host" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/controlledfeature" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/api" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "go.uber.org/zap" +) + +// TODO move it to 'api' package + +// Connection is a client interacting with OpsManager API. Note, that all methods returning 'error' return the +// '*Error' in fact, but it's error-prone to declare method as returning specific implementation of error +// (see https://golang.org/doc/faq#nil_error) +type Connection interface { + UpdateDeployment(deployment Deployment) ([]byte, error) + ReadDeployment() (Deployment, error) + + // ReadUpdateDeployment reads Deployment from Ops Manager, applies the update function to it and pushes it back + ReadUpdateDeployment(depFunc func(Deployment) error, log *zap.SugaredLogger) error + + ReadAutomationStatus() (*AutomationStatus, error) + ReadAutomationAgents(page int) (Paginated, error) + MarkProjectAsBackingDatabase(databaseType BackingDatabaseType) error + + ReadOrganizationsByName(name string) ([]*Organization, error) + // ReadOrganizations returns all organizations at specified page + ReadOrganizations(page int) (Paginated, error) + ReadOrganization(orgID string) (*Organization, error) + + ReadProjectsInOrganizationByName(orgID string, name string) ([]*Project, error) + // ReadProjectsInOrganization returns all projects in the organization at the specified page + ReadProjectsInOrganization(orgID string, page int) (Paginated, error) + CreateProject(project *Project) (*Project, error) + UpdateProject(project *Project) (*Project, error) + + backup.GroupConfigReader + backup.GroupConfigUpdater + + backup.HostClusterReader + + backup.ConfigReader + backup.ConfigUpdater + + OpsManagerVersion() versionutil.OpsManagerVersion + + AgentKeyGenerator + + AutomationConfigConnection + MonitoringConfigConnection + BackupConfigConnection + HasAgentAuthMode + + host.Adder + host.GetRemover + host.Updater + + controlledfeature.Getter + controlledfeature.Updater + + BaseURL() string + GroupID() string + GroupName() string + OrgID() string + PublicKey() string + PrivateKey() string + + // ConfigureProject configures the OMContext to have the correct project and org ids + ConfigureProject(project *Project) +} + +type MonitoringConfigConnection interface { + ReadMonitoringAgentConfig() (*MonitoringAgentConfig, error) + UpdateMonitoringAgentConfig(mat *MonitoringAgentConfig, log *zap.SugaredLogger) ([]byte, error) + ReadUpdateMonitoringAgentConfig(matFunc func(*MonitoringAgentConfig) error, log *zap.SugaredLogger) error +} + +type BackupConfigConnection interface { + ReadBackupAgentConfig() (*BackupAgentConfig, error) + UpdateBackupAgentConfig(mat *BackupAgentConfig, log *zap.SugaredLogger) ([]byte, error) + ReadUpdateBackupAgentConfig(matFunc func(*BackupAgentConfig) error, log *zap.SugaredLogger) error +} + +type HasAgentAuthMode interface { + GetAgentAuthMode() (string, error) +} + +type AgentKeyGenerator interface { + GenerateAgentKey() (string, error) +} + +// AutomationConfigConnection is an interface that only deals with reading/updating of the AutomationConfig +type AutomationConfigConnection interface { + // UpdateAutomationConfig updates the Automation Config in Ops Manager + // Note, that this method calls *the same* api endpoint as the `OmConnection.UpdateDeployment` - just uses a + // Deployment wrapper (AutomationConfig) as a parameter + UpdateAutomationConfig(ac *AutomationConfig, log *zap.SugaredLogger) error + ReadAutomationConfig() (*AutomationConfig, error) + // ReadAutomationConfig reads the Automation Config from Ops Manager + // Note, that this method calls *the same* api endpoint as the `OmConnection.ReadDeployment` - just wraps the answer + // to the different object + ReadUpdateAutomationConfig(acFunc func(ac *AutomationConfig) error, log *zap.SugaredLogger) error + + // Calls the API to update all the MongoDB Agents in the project to latest. Returns the new version + UpgradeAgentsToLatest() (string, error) +} + +// omMutexes is the synchronous map of mutexes that provide strict serializability for operations "read-modify-write" +// for Ops Manager. Keys are (group_name + org_id) and values are mutexes. +var omMutexes = sync.Map{} + +// GetMutex creates or reuses the relevant mutex for the group + org +func GetMutex(projectName, orgId string) *sync.Mutex { + lockName := projectName + orgId + mutex, _ := omMutexes.LoadOrStore(lockName, &sync.Mutex{}) + return mutex.(*sync.Mutex) +} + +type BackingDatabaseType string + +const ( + AppDBDatabaseType BackingDatabaseType = "APP_DB" + BlockStoreDatabaseType BackingDatabaseType = "BLOCKSTORE_DB" + OplogDatabaseType BackingDatabaseType = "OPLOG_DB" +) + +// ConnectionFactory type defines a function to create a connection to Ops Manager API. +// That's the way we implement some kind of Template Factory pattern to create connections using some incoming parameters +// (groupId, api key etc - all encapsulated into 'context'). The reconciler later uses this factory to build real +// connections and this allows us to mock out Ops Manager communication during unit testing +type ConnectionFactory func(context *OMContext) Connection + +// OMContext is the convenient way of grouping all OM related information together +type OMContext struct { + BaseURL string + GroupID string + GroupName string + OrgID string + PrivateKey string + PublicKey string + Version versionutil.OpsManagerVersion + + // Will check that the SSL certificate provided by the Ops Manager Server is valid + // I've decided to use a "AllowInvalid" instead of "RequireValid" as the Zero value + // of bool is false. + AllowInvalidSSLCertificate bool + + // CACertificate is the actual certificate as a string, as every "Project" could have + // its own certificate. + CACertificate string +} + +// HTTPOmConnection +type HTTPOmConnection struct { + context *OMContext + + client *api.Client +} + +func (oc *HTTPOmConnection) GetAgentAuthMode() (string, error) { + ac, err := oc.ReadAutomationConfig() + if err != nil { + return "", err + } + if ac.Auth == nil { + return "", nil + } + return ac.Auth.AutoAuthMechanism, nil +} + +var _ Connection = &HTTPOmConnection{} + +// NewOpsManagerConnection stores OpsManger api endpoint and authentication credentials. +// It makes it easy to call the API without having to explicitly provide connection details. +func NewOpsManagerConnection(context *OMContext) Connection { + return &HTTPOmConnection{ + context: context, + } +} + +func (oc *HTTPOmConnection) ReadGroupBackupConfig() (backup.GroupBackupConfig, error) { + ans, apiErr := oc.get(fmt.Sprintf("/api/public/v1.0/admin/backup/groups/%s", oc.GroupID())) + + if apiErr != nil { + // This API provides very inconsistent way for obtaining values and authorization. In certain Ops Manager versions + // when there's no Group Backup Config we get 404 (which is inconsistent with the UI, as the endpoints for UI + // always return values). In Ops Manager 6, if this object doesn't yet exist, we get 401. So the only reasonable + // thing to do here is to check whether the error comes from the Ops Manager (if we can parse it), and if we do, + // we just ignore it. + _, ok := apiErr.(*apierror.Error) + if ok { + return backup.GroupBackupConfig{ + Id: pointer.String(oc.GroupID()), + }, nil + } + return backup.GroupBackupConfig{}, apiErr + } + groupBackupConfig := &backup.GroupBackupConfig{} + if err := json.Unmarshal(ans, groupBackupConfig); err != nil { + return backup.GroupBackupConfig{}, apierror.New(err) + } + + return *groupBackupConfig, nil +} + +func (oc *HTTPOmConnection) UpdateGroupBackupConfig(config backup.GroupBackupConfig) ([]byte, error) { + return oc.put(fmt.Sprintf("/api/public/v1.0/admin/backup/groups/%s", *config.Id), config) +} + +func (oc *HTTPOmConnection) ConfigureProject(project *Project) { + oc.context.GroupID = project.ID + oc.context.OrgID = project.OrgID +} + +// BaseURL returns BaseURL of HTTPOmConnection +func (oc *HTTPOmConnection) BaseURL() string { + return oc.context.BaseURL +} + +// GroupID returns GroupID of HTTPOmConnection +func (oc *HTTPOmConnection) GroupID() string { + return oc.context.GroupID +} + +// GroupName returns GroupName of HTTPOmConnection +func (oc *HTTPOmConnection) GroupName() string { + return oc.context.GroupName +} + +// OrgID returns OrgID of HTTPOmConnection +func (oc *HTTPOmConnection) OrgID() string { + return oc.context.OrgID +} + +// PublicKey returns PublicKey of HTTPOmConnection +func (oc *HTTPOmConnection) PublicKey() string { + return oc.context.PublicKey + +} + +// PrivateKey returns PrivateKey of HTTPOmConnection +func (oc *HTTPOmConnection) PrivateKey() string { + return oc.context.PrivateKey +} + +// GetOpsManagerVersion returns the current Ops Manager version +func (oc *HTTPOmConnection) OpsManagerVersion() versionutil.OpsManagerVersion { + return oc.context.Version +} + +// UpdateDeployment updates a given deployment to the new deployment object passed as parameter. +func (oc *HTTPOmConnection) UpdateDeployment(deployment Deployment) ([]byte, error) { + return oc.put(fmt.Sprintf("/api/public/v1.0/groups/%s/automationConfig", oc.GroupID()), deployment) +} + +// ReadDeployment returns a Deployment object for this group +func (oc *HTTPOmConnection) ReadDeployment() (Deployment, error) { + ans, err := oc.get(fmt.Sprintf("/api/public/v1.0/groups/%s/automationConfig", oc.GroupID())) + + if err != nil { + return nil, err + } + d, e := BuildDeploymentFromBytes(ans) + return d, apierror.New(e) +} + +func (oc *HTTPOmConnection) ReadAutomationConfig() (*AutomationConfig, error) { + ans, err := oc.get(fmt.Sprintf("/api/public/v1.0/groups/%s/automationConfig", oc.GroupID())) + + if err != nil { + return nil, err + } + + ac, err := BuildAutomationConfigFromBytes(ans) + + return ac, apierror.New(err) +} + +// ReadUpdateDeployment performs the "read-modify-update" operation on OpsManager Deployment. +// Note, that the mutex locks infinitely (there is no built-in support for timeouts for locks in Go) which seems to be +// ok as OM endpoints are not supposed to hang for long +func (oc *HTTPOmConnection) ReadUpdateDeployment(changeDeploymentFunc func(Deployment) error, log *zap.SugaredLogger) error { + mutex := GetMutex(oc.GroupName(), oc.OrgID()) + mutex.Lock() + defer mutex.Unlock() + deployment, err := oc.ReadDeployment() + if err != nil { + return err + } + + isEqual, err := isEqualAfterModification(changeDeploymentFunc, deployment) + if err != nil { + return err + } + if isEqual { + log.Debug("AutomationConfig has not changed, not pushing changes to Ops Manager") + return nil + } + + _, err = oc.UpdateDeployment(deployment) + if err != nil { + if util.ShouldLogAutomationConfigDiff() { + originalDeployment, err := oc.ReadDeployment() + if err != nil { + return apierror.New(err) + } + + changelog, err := diff.Diff(originalDeployment, deployment, diff.AllowTypeMismatch(true)) + if err != nil { + return apierror.New(err) + } + + log.Debug("Deployment diff (%d changes): %+v", len(changelog), changelog) + } + return apierror.New(err) + } + return nil +} + +func (oc *HTTPOmConnection) UpdateAutomationConfig(ac *AutomationConfig, log *zap.SugaredLogger) error { + err := ac.Apply() + if err != nil { + return err + } + + _, err = oc.UpdateDeployment(ac.Deployment) + if err != nil { + return err + } + return nil +} + +func (oc *HTTPOmConnection) ReadUpdateAutomationConfig(modifyACFunc func(ac *AutomationConfig) error, log *zap.SugaredLogger) error { + mutex := GetMutex(oc.GroupName(), oc.OrgID()) + mutex.Lock() + defer mutex.Unlock() + + ac, err := oc.ReadAutomationConfig() + if err != nil { + log.Errorf("error reading automation config. %s", err) + return err + } + + original, err := BuildAutomationConfigFromDeployment(ac.Deployment) + if err != nil { + return err + } + + if err := modifyACFunc(ac); err != nil { + return apierror.New(err) + } + + if !reflect.DeepEqual(original.Deployment, ac.Deployment) { + panic("It seems you modified the deployment directly. This is not allowed. Please use helper objects instead.") + } + + areEqual := original.EqualsWithoutDeployment(*ac) + if areEqual { + log.Debug("AutomationConfig has not changed, not pushing changes to Ops Manager") + return nil + } + + // we are using UpdateAutomationConfig since we need to apply our changes. + err = oc.UpdateAutomationConfig(ac, log) + if err != nil { + if util.ShouldLogAutomationConfigDiff() { + var originalDeployment = original.Deployment + log.Debug("Current Automation Config") + originalDeployment.Debug(log) + log.Debug("Invalid Automation Config") + ac.Deployment.Debug(log) + } + log.Errorf("error updating automation config. %s", err) + return apierror.New(err) + } + return nil +} + +func (oc *HTTPOmConnection) UpgradeAgentsToLatest() (string, error) { + ans, err := oc.post(fmt.Sprintf("/api/public/v1.0/groups/%s/automationConfig/updateAgentVersions", oc.GroupID()), nil) + + if err != nil { + return "", err + } + type updateAgentsVersionsResponse struct { + AutomationAgentVersion string `json:"automationAgentVersion"` + } + var response updateAgentsVersionsResponse + if err = json.Unmarshal(ans, &response); err != nil { + return "", apierror.New(err) + } + return response.AutomationAgentVersion, nil +} + +// GenerateAgentKey +func (oc *HTTPOmConnection) GenerateAgentKey() (string, error) { + data := map[string]string{"desc": "Agent key for Kubernetes"} + ans, err := oc.post(fmt.Sprintf("/api/public/v1.0/groups/%s/agentapikeys", oc.GroupID()), data) + + if err != nil { + return "", err + } + + var keyInfo map[string]interface{} + if err := json.Unmarshal(ans, &keyInfo); err != nil { + return "", apierror.New(err) + } + return keyInfo["key"].(string), nil +} + +// ReadAutomationAgents returns the state of the automation agents registered in Ops Manager +func (oc *HTTPOmConnection) ReadAutomationAgents(pageNum int) (Paginated, error) { + // TODO: Add proper testing to this pagination. In order to test it I just used `itemsPerPage=1`, which will make + // the endpoint to be called 3 times in a 3 member replica set. The default itemsPerPage is 100 + ans, err := oc.get(fmt.Sprintf("/api/public/v1.0/groups/%s/agents/AUTOMATION?pageNum=%d", oc.GroupID(), pageNum)) + if err != nil { + return nil, err + } + var resp automationAgentStatusResponse + if err := json.Unmarshal(ans, &resp); err != nil { + return nil, err + } + return resp, apierror.New(err) +} + +// ReadAutomationStatus returns the state of the automation status, this includes if the agents +// have reached goal state. +func (oc *HTTPOmConnection) ReadAutomationStatus() (*AutomationStatus, error) { + ans, err := oc.get(fmt.Sprintf("/api/public/v1.0/groups/%s/automationStatus", oc.GroupID())) + if err != nil { + return nil, err + } + status, e := buildAutomationStatusFromBytes(ans) + return status, apierror.New(e) +} + +// AddHost adds the given host to the project +func (oc *HTTPOmConnection) AddHost(host host.Host) error { + _, err := oc.post(fmt.Sprintf("/api/public/v1.0/groups/%s/hosts", oc.GroupID()), host) + return err +} + +// UpdateHost adds the given host. +func (oc *HTTPOmConnection) UpdateHost(host host.Host) error { + _, err := oc.patch(fmt.Sprintf("/api/public/v1.0/groups/%s/hosts/%s", oc.GroupID(), host.Id), host) + return err +} + +// GetHosts return the hosts in this group +func (oc *HTTPOmConnection) GetHosts() (*host.Result, error) { + mPath := fmt.Sprintf("/api/public/v1.0/groups/%s/hosts/", oc.GroupID()) + res, err := oc.get(mPath) + if err != nil { + return nil, err + } + + hosts := &host.Result{} + if err := json.Unmarshal(res, hosts); err != nil { + return nil, apierror.New(err) + } + + return hosts, nil +} + +// RemoveHost will remove host, identified by hostID from group +func (oc *HTTPOmConnection) RemoveHost(hostID string) error { + mPath := fmt.Sprintf("/api/public/v1.0/groups/%s/hosts/%s", oc.GroupID(), hostID) + return oc.delete(mPath) +} + +// ReadOrganizationsByName finds the organizations by name. It uses the same endpoint as the 'ReadOrganizations' but +// 'name' and 'page' parameters are not supposed to be used together so having a separate endpoint allows +func (oc *HTTPOmConnection) ReadOrganizationsByName(name string) ([]*Organization, error) { + mPath := fmt.Sprintf("/api/public/v1.0/orgs?name=%s", url.QueryEscape(name)) + res, err := oc.get(mPath) + if err != nil { + return nil, err + } + + orgsResponse := &OrganizationsResponse{} + if err = json.Unmarshal(res, orgsResponse); err != nil { + return nil, apierror.New(err) + } + + return orgsResponse.Organizations, nil +} + +// ReadOrganizations returns all organizations at the specified page. +func (oc *HTTPOmConnection) ReadOrganizations(page int) (Paginated, error) { + mPath := fmt.Sprintf("/api/public/v1.0/orgs?itemsPerPage=500&pageNum=%d", page) + res, err := oc.get(mPath) + if err != nil { + return nil, err + } + + orgsResponse := &OrganizationsResponse{} + if err := json.Unmarshal(res, orgsResponse); err != nil { + return nil, apierror.New(err) + } + + return orgsResponse, nil +} + +func (oc *HTTPOmConnection) ReadOrganization(orgID string) (*Organization, error) { + ans, err := oc.get(fmt.Sprintf("/api/public/v1.0/orgs/%s", orgID)) + if err != nil { + return nil, err + } + organization := &Organization{} + if err := json.Unmarshal(ans, organization); err != nil { + return nil, apierror.New(err) + } + + return organization, nil +} + +func (oc *HTTPOmConnection) MarkProjectAsBackingDatabase(backingType BackingDatabaseType) error { + _, err := oc.post(fmt.Sprintf("/api/private/v1.0/groups/%s/markAsBackingDatabase", oc.GroupID()), string(backingType)) + if err != nil { + if apiErr, ok := err.(*apierror.Error); ok { + if apiErr.Status != nil && *apiErr.Status == 400 && strings.Contains(apiErr.Detail, "INVALID_DOCUMENT") { + return nil + } + } + return err + } + return nil +} + +func (oc *HTTPOmConnection) ReadProjectsInOrganizationByName(orgID string, name string) ([]*Project, error) { + mPath := fmt.Sprintf("/api/public/v1.0/orgs/%s/groups?name=%s", orgID, url.QueryEscape(name)) + res, err := oc.get(mPath) + if err != nil { + return nil, err + } + + projectsResponse := &ProjectsResponse{} + if err := json.Unmarshal(res, projectsResponse); err != nil { + return nil, apierror.New(err) + } + + return projectsResponse.Groups, nil +} + +// ReadProjectsInOrganization returns all projects inside organization +func (oc *HTTPOmConnection) ReadProjectsInOrganization(orgID string, page int) (Paginated, error) { + mPath := fmt.Sprintf("/api/public/v1.0/orgs/%s/groups?itemsPerPage=500&pageNum=%d", orgID, page) + res, err := oc.get(mPath) + if err != nil { + return nil, err + } + + projectsResponse := &ProjectsResponse{} + if err := json.Unmarshal(res, projectsResponse); err != nil { + return nil, apierror.New(err) + } + + return projectsResponse, nil +} + +// CreateProject +func (oc *HTTPOmConnection) CreateProject(project *Project) (*Project, error) { + res, err := oc.post("/api/public/v1.0/groups", project) + + if err != nil { + return nil, err + } + + g := &Project{} + if err := json.Unmarshal(res, g); err != nil { + return nil, apierror.New(err) + } + + return g, nil +} + +// UpdateProject +func (oc *HTTPOmConnection) UpdateProject(project *Project) (*Project, error) { + path := fmt.Sprintf("/api/public/v1.0/groups/%s", project.ID) + res, err := oc.patch(path, project) + + if err != nil { + return nil, err + } + + g := &Project{} + if err := json.Unmarshal(res, g); err != nil { + return nil, apierror.New(err) + } + + return project, nil +} + +// ReadBackupConfigs +func (oc *HTTPOmConnection) ReadBackupConfigs() (*backup.ConfigsResponse, error) { + mPath := fmt.Sprintf("/api/public/v1.0/groups/%s/backupConfigs", oc.GroupID()) + res, err := oc.get(mPath) + if err != nil { + return nil, err + } + + response := &backup.ConfigsResponse{} + if err := json.Unmarshal(res, response); err != nil { + return nil, apierror.New(err) + } + + return response, nil +} + +// ReadBackupConfig +func (oc *HTTPOmConnection) ReadBackupConfig(clusterID string) (*backup.Config, error) { + mPath := fmt.Sprintf("/api/public/v1.0/groups/%s/backupConfigs/%s", oc.GroupID(), clusterID) + res, err := oc.get(mPath) + if err != nil { + return nil, err + } + + response := &backup.Config{} + if err := json.Unmarshal(res, response); err != nil { + return nil, apierror.New(err) + } + + return response, nil +} + +// UpdateBackupConfig +func (oc *HTTPOmConnection) UpdateBackupConfig(config *backup.Config) (*backup.Config, error) { + path := fmt.Sprintf("/api/public/v1.0/groups/%s/backupConfigs/%s", oc.GroupID(), config.ClusterId) + res, err := oc.patch(path, config) + if err != nil { + return nil, err + } + + response := &backup.Config{} + if err := json.Unmarshal(res, response); err != nil { + return nil, apierror.New(err) + } + return response, nil +} + +// ReadHostCluster +func (oc *HTTPOmConnection) ReadHostCluster(clusterID string) (*backup.HostCluster, error) { + mPath := fmt.Sprintf("/api/public/v1.0/groups/%s/clusters/%s", oc.GroupID(), clusterID) + res, err := oc.get(mPath) + if err != nil { + return nil, err + } + + cluster := &backup.HostCluster{} + if err := json.Unmarshal(res, cluster); err != nil { + return nil, apierror.New(err) + } + + return cluster, nil +} + +// UpdateBackupStatus +func (oc *HTTPOmConnection) UpdateBackupStatus(clusterID string, status backup.Status) error { + path := fmt.Sprintf("/api/public/v1.0/groups/%s/backupConfigs/%s", oc.GroupID(), clusterID) + + _, err := oc.patch(path, map[string]interface{}{"statusName": status}) + + if err != nil { + return apierror.New(err) + } + + return nil +} + +func (oc *HTTPOmConnection) ReadMonitoringAgentConfig() (*MonitoringAgentConfig, error) { + ans, err := oc.get(fmt.Sprintf("/api/public/v1.0/groups/%s/automationConfig/monitoringAgentConfig", oc.GroupID())) + if err != nil { + return nil, err + } + + mat, err := BuildMonitoringAgentConfigFromBytes(ans) + + if err != nil { + return nil, err + } + return mat, nil +} + +func (oc *HTTPOmConnection) UpdateMonitoringAgentConfig(mat *MonitoringAgentConfig, log *zap.SugaredLogger) ([]byte, error) { + err := mat.Apply() + if err != nil { + return nil, err + } + return oc.put(fmt.Sprintf("/api/public/v1.0/groups/%s/automationConfig/monitoringAgentConfig", oc.GroupID()), mat.BackingMap) +} + +func (oc *HTTPOmConnection) ReadUpdateMonitoringAgentConfig(modifyMonitoringAgentFunction func(*MonitoringAgentConfig) error, log *zap.SugaredLogger) error { + if log == nil { + log = zap.S() + } + mutex := GetMutex(oc.GroupName(), oc.OrgID()) + mutex.Lock() + defer mutex.Unlock() + + mat, err := oc.ReadMonitoringAgentConfig() + if err != nil { + return err + } + + if err := modifyMonitoringAgentFunction(mat); err != nil { + return err + } + + if _, err := oc.UpdateMonitoringAgentConfig(mat, log); err != nil { + return err + } + + return nil +} + +func (oc *HTTPOmConnection) ReadBackupAgentConfig() (*BackupAgentConfig, error) { + ans, err := oc.get(fmt.Sprintf("/api/public/v1.0/groups/%s/automationConfig/backupAgentConfig", oc.GroupID())) + if err != nil { + return nil, err + } + + backup, err := BuildBackupAgentConfigFromBytes(ans) + + if err != nil { + return nil, err + } + + return backup, nil +} + +func (oc *HTTPOmConnection) UpdateBackupAgentConfig(backup *BackupAgentConfig, log *zap.SugaredLogger) ([]byte, error) { + original, _ := util.MapDeepCopy(backup.BackingMap) + + err := backup.Apply() + if err != nil { + return nil, err + } + + if reflect.DeepEqual(original, backup.BackingMap) { + log.Debug("Backup Configuration has not changed, not pushing changes to Ops Manager") + } else { + return oc.put(fmt.Sprintf("/api/public/v1.0/groups/%s/automationConfig/backupAgentConfig", oc.GroupID()), backup.BackingMap) + } + + return nil, nil +} + +func (oc *HTTPOmConnection) ReadUpdateBackupAgentConfig(backupFunc func(*BackupAgentConfig) error, log *zap.SugaredLogger) error { + if log == nil { + log = zap.S() + } + mutex := GetMutex(oc.GroupName(), oc.OrgID()) + mutex.Lock() + defer mutex.Unlock() + + backup, err := oc.ReadBackupAgentConfig() + if err != nil { + return err + } + + if err := backupFunc(backup); err != nil { + return err + } + + if _, err := oc.UpdateBackupAgentConfig(backup, log); err != nil { + return err + } + + return nil +} + +func (oc *HTTPOmConnection) UpdateControlledFeature(cf *controlledfeature.ControlledFeature) error { + _, err := oc.put(fmt.Sprintf("/api/public/v1.0/groups/%s/controlledFeature", oc.GroupID()), cf) + return err +} + +func (oc *HTTPOmConnection) GetControlledFeature() (*controlledfeature.ControlledFeature, error) { + res, err := oc.get(fmt.Sprintf("/api/public/v1.0/groups/%s/controlledFeature", oc.GroupID())) + if err != nil { + return nil, err + } + cf := &controlledfeature.ControlledFeature{} + if err := json.Unmarshal(res, cf); err != nil { + return nil, apierror.New(err) + } + return cf, nil +} + +func (oc *HTTPOmConnection) ReadSnapshotSchedule(clusterID string) (*backup.SnapshotSchedule, error) { + mPath := fmt.Sprintf("/api/public/v1.0/groups/%s/backupConfigs/%s/snapshotSchedule", oc.GroupID(), clusterID) + res, err := oc.get(mPath) + if err != nil { + return nil, err + } + + response := &backup.SnapshotSchedule{} + if err := json.Unmarshal(res, response); err != nil { + return nil, apierror.New(err) + } + + // OM returns 0 if not set instead of null or omitted field + if response.ClusterCheckpointIntervalMin != nil && *response.ClusterCheckpointIntervalMin == 0 { + response.ClusterCheckpointIntervalMin = nil + } + + return response, nil +} + +func (oc *HTTPOmConnection) UpdateSnapshotSchedule(clusterID string, snapshotSchedule *backup.SnapshotSchedule) error { + path := fmt.Sprintf("/api/public/v1.0/groups/%s/backupConfigs/%s/snapshotSchedule", oc.GroupID(), clusterID) + res, err := oc.patch(path, snapshotSchedule) + if err != nil { + return err + } + + if err := json.Unmarshal(res, &backup.SnapshotSchedule{}); err != nil { + return apierror.New(err) + } + return nil +} + +//********************************** Private methods ******************************************************************* + +func (oc *HTTPOmConnection) get(path string) ([]byte, error) { + return oc.httpVerb("GET", path, nil) +} + +func (oc *HTTPOmConnection) post(path string, v interface{}) ([]byte, error) { + return oc.httpVerb("POST", path, v) +} + +func (oc *HTTPOmConnection) put(path string, v interface{}) ([]byte, error) { + return oc.httpVerb("PUT", path, v) +} + +func (oc *HTTPOmConnection) patch(path string, v interface{}) ([]byte, error) { + return oc.httpVerb("PATCH", path, v) +} + +func (oc *HTTPOmConnection) delete(path string) error { + _, err := oc.httpVerb("DELETE", path, nil) + return err +} + +func (oc *HTTPOmConnection) httpVerb(method, path string, v interface{}) ([]byte, error) { + client, err := oc.getHTTPClient() + if err != nil { + return nil, err + } + + response, header, err := client.Request(method, oc.BaseURL(), path, v) + if header != nil { + oc.context.Version = versionutil.OpsManagerVersion{ + VersionString: versionutil.GetVersionFromOpsManagerApiHeader(header.Get("X-MongoDB-Service-Version")), + } + } + + return response, err +} + +// getHTTPClient gets a new or an already existing client. +func (oc *HTTPOmConnection) getHTTPClient() (*api.Client, error) { + if oc.client != nil { + return oc.client, nil + } + + opts := api.NewHTTPOptions() + + if oc.context.CACertificate != "" { + zap.S().Debug("Using CA Certificate") + opts = append(opts, api.OptionCAValidate(oc.context.CACertificate)) + } + + if oc.context.AllowInvalidSSLCertificate { + zap.S().Debug("Allowing insecure certs") + opts = append(opts, api.OptionSkipVerify) + } + + opts = append(opts, api.OptionDigestAuth(oc.PublicKey(), oc.PrivateKey())) + + if env.ReadBoolOrDefault("OM_DEBUG_HTTP", false) { + opts = append(opts, api.OptionDebug) + } + + client, err := api.NewHTTPClient(opts...) + if err != nil { + return nil, err + } + oc.client = client + + return oc.client, nil +} diff --git a/controllers/om/omclient_test.go b/controllers/om/omclient_test.go new file mode 100644 index 000000000..4c41f89aa --- /dev/null +++ b/controllers/om/omclient_test.go @@ -0,0 +1,207 @@ +package om + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" + + "github.com/stretchr/testify/assert" + "go.uber.org/zap" +) + +func init() { + logger, _ := zap.NewDevelopment() + zap.ReplaceGlobals(logger) +} + +func TestReadProjectsInOrganizationByName(t *testing.T) { + projects := []*Project{{ID: "111", Name: "The Project"}} + srv := serverMock(projectsInOrganizationByName("testOrgId", projects)) + defer srv.Close() + + connection := NewOpsManagerConnection(&OMContext{BaseURL: srv.URL}) + + data, err := connection.ReadProjectsInOrganizationByName("testOrgId", "The Project") + assert.NoError(t, err) + assert.Equal(t, projects, data) +} + +func TestReadOrganizationsByName(t *testing.T) { + organizations := []*Organization{{ID: "111", Name: "The Organization"}} + srv := serverMock(organizationsByName(organizations)) + defer srv.Close() + + connection := NewOpsManagerConnection(&OMContext{BaseURL: srv.URL}) + + data, err := connection.ReadOrganizationsByName("The Organization") + assert.NoError(t, err) + assert.Equal(t, organizations, data) +} + +func TestGettingAutomationConfig(t *testing.T) { + testAutomationConfig := getTestAutomationConfig() + handleFunc, _ := automationConfig("1", automationConfigResponse{config: testAutomationConfig}) + srv := serverMock(handleFunc) + defer srv.Close() + + connection := NewOpsManagerConnection(&OMContext{BaseURL: srv.URL, GroupID: "1"}) + data, err := connection.ReadAutomationConfig() + + assert.NoError(t, err) + assert.Equal(t, testAutomationConfig.Deployment, data.Deployment) +} + +func TestNotSendingRequestOnNonModifiedAutomationConfig(t *testing.T) { + logger := zap.NewNop().Sugar() + testAutomationConfig := getTestAutomationConfig() + handleFunc, counters := automationConfig("1", automationConfigResponse{config: testAutomationConfig}) + srv := serverMock(handleFunc) + defer srv.Close() + + connection := NewOpsManagerConnection(&OMContext{BaseURL: srv.URL, GroupID: "1"}) + err := connection.ReadUpdateAutomationConfig(func(ac *AutomationConfig) error { + return nil + }, logger) + + assert.NoError(t, err) + assert.Equal(t, 1, counters.getHitCount) + assert.Equal(t, 0, counters.putHitCount) +} + +// TestNotSendingRequestOnNonModifiedAutomationConfigWithMergoDelete verifies that util.MergoDelete will be ignored during equality comparisons +func TestNotSendingRequestOnNonModifiedAutomationConfigWithMergoDelete(t *testing.T) { + logger := zap.NewNop().Sugar() + testAutomationConfig := getTestAutomationConfig() + handleFunc, counters := automationConfig("1", automationConfigResponse{config: testAutomationConfig}) + srv := serverMock(handleFunc) + defer srv.Close() + + connection := NewOpsManagerConnection(&OMContext{BaseURL: srv.URL, GroupID: "1"}) + err := connection.ReadUpdateAutomationConfig(func(ac *AutomationConfig) error { + ac.AgentSSL = &AgentSSL{ + AutoPEMKeyFilePath: util.MergoDelete, + } + return nil + }, logger) + + assert.NoError(t, err) + assert.Equal(t, 1, counters.getHitCount) + assert.Equal(t, 0, counters.putHitCount) +} + +func TestRetriesOnWritingAutomationConfig(t *testing.T) { + logger := zap.NewNop().Sugar() + testAutomationConfig := getTestAutomationConfig() + successfulResponse := automationConfigResponse{config: testAutomationConfig} + errorResponse := automationConfigResponse{errorCode: 500, errorString: "testing"} + handleFunc, counters := automationConfig("1", errorResponse, errorResponse, successfulResponse) + srv := serverMock(handleFunc) + defer srv.Close() + + connection := NewOpsManagerConnection(&OMContext{BaseURL: srv.URL, GroupID: "1"}) + err := connection.ReadUpdateAutomationConfig(func(ac *AutomationConfig) error { + return nil + }, logger) + + assert.NoError(t, err) + assert.Equal(t, 3, counters.getHitCount) +} + +// ******************************* Mock HTTP Server methods ***************************************************** + +type handleFunc func(mux *http.ServeMux) + +type counters struct { + getHitCount int + putHitCount int + totalCount int +} + +func serverMock(handlers ...handleFunc) *httptest.Server { + + handler := http.NewServeMux() + for _, h := range handlers { + h(handler) + } + + srv := httptest.NewServer(handler) + + return srv +} + +func projectsInOrganizationByName(orgId string, projects []*Project) handleFunc { + return func(mux *http.ServeMux) { + mux.HandleFunc(fmt.Sprintf("/api/public/v1.0/orgs/%s/groups", orgId), + func(w http.ResponseWriter, r *http.Request) { + found := false + for _, p := range projects { + if p.Name == r.URL.Query()["name"][0] { + found = true + } + } + if !found { + w.WriteHeader(http.StatusNotFound) + return + } + response := ProjectsResponse{Groups: projects} + data, _ := json.Marshal(response) + _, _ = w.Write(data) + }) + } +} + +func organizationsByName(organizations []*Organization) handleFunc { + return func(mux *http.ServeMux) { + mux.HandleFunc("/api/public/v1.0/orgs", + func(w http.ResponseWriter, r *http.Request) { + found := false + for _, p := range organizations { + if p.Name == r.URL.Query()["name"][0] { + found = true + } + } + if !found { + w.WriteHeader(http.StatusNotFound) + return + } + response := OrganizationsResponse{Organizations: organizations} + data, _ := json.Marshal(response) + _, _ = w.Write(data) + }) + } +} + +type automationConfigResponse struct { + config *AutomationConfig + errorCode int + errorString string +} + +func automationConfig(groupId string, responses ...automationConfigResponse) (handleFunc, *counters) { + counters := &counters{} + handle := func(mux *http.ServeMux) { + mux.HandleFunc(fmt.Sprintf("/api/public/v1.0/groups/%s/automationConfig", groupId), + func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + counters.getHitCount = counters.getHitCount + 1 + response := responses[counters.totalCount] + if response.config != nil { + data, _ := json.Marshal(response.config.Deployment) + _, _ = w.Write(data) + } else if response.errorCode != 0 { + http.Error(w, response.errorString, response.errorCode) + } + case "PUT": + counters.putHitCount = counters.putHitCount + 1 + w.WriteHeader(http.StatusOK) + } + counters.totalCount = counters.totalCount + 1 + }) + } + return handle, counters +} diff --git a/controllers/om/ompaginator.go b/controllers/om/ompaginator.go new file mode 100644 index 000000000..67dfa31e6 --- /dev/null +++ b/controllers/om/ompaginator.go @@ -0,0 +1,80 @@ +package om + +// Paginated is the general interface for a single page returned by Ops Manager api. +type Paginated interface { + HasNext() bool + Results() []interface{} + ItemsCount() int +} + +type OMPaginated struct { + TotalCount int `json:"totalCount"` + Links []*Link `json:"links,omitempty"` +} + +type Link struct { + Rel string `json:"rel"` +} + +// HasNext return true if there is next page (see 'ApiBaseResource.handlePaginationInternal` in mms code) +func (o OMPaginated) HasNext() bool { + for _, l := range o.Links { + if l.Rel == "next" { + return true + } + } + return false +} + +func (o OMPaginated) ItemsCount() int { + return o.TotalCount +} + +// PageReader is the function that reads a single page by its number +type PageReader func(pageNum int) (Paginated, error) + +// PageItemPredicate is the function that processes single item on the page and returns true if no further processing +// needs to be done (usually it's the search logic) +type PageItemPredicate func(interface{}) bool + +// TraversePages reads page after page using 'apiFunc' and applies the 'predicate' for each item on the page. +// Stops traversal when the 'predicate' returns true +// Note, that in OM 4.0 the max number of pages is 100, but in OM 4.1 and CM - 500. +// So we'll traverse 100000 (200 pages 500 items on each) records in Cloud Manager and 20000 records in OM 4.0 - I believe it's ok +// This won't be necessary if MMS-5638 is implemented or if we make 'orgId' configuration mandatory +func TraversePages(reader PageReader, predicate PageItemPredicate) (found bool, err error) { + // First we check the first page and get the number of items to calculate the max number of pages to traverse + paginated, e := reader(1) + if e != nil { + return false, e + } + for _, entity := range paginated.Results() { + if predicate(entity) { + return true, nil + } + } + if !paginated.HasNext() { + return false, nil + } + + // We take 100 as the denuminator here assuming it's the OM 4.0. If it's OM 4.1 or CM - then we'll stop earlier + // thanks to '!paginated.HasNext()' as they support pages of size 500 + pagesNum := (paginated.ItemsCount() / 100) + 1 + + // Note that we start from 2nd page as we've checked the 1st one above + for i := 2; i <= pagesNum; i++ { + paginated, e := reader(i) + if e != nil { + return false, e + } + for _, entity := range paginated.Results() { + if predicate(entity) { + return true, nil + } + } + if !paginated.HasNext() { + return false, nil + } + } + return false, nil +} diff --git a/controllers/om/ompaginator_test.go b/controllers/om/ompaginator_test.go new file mode 100644 index 000000000..2c8e65eb9 --- /dev/null +++ b/controllers/om/ompaginator_test.go @@ -0,0 +1,82 @@ +package om + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPagination_SinglePage(t *testing.T) { + found, err := TraversePages(singleOrganizationsPage, func(obj interface{}) bool { return obj.(*Organization).Name == "test" }) + assert.True(t, found) + assert.NoError(t, err) + + found, err = TraversePages(singleOrganizationsPage, func(obj interface{}) bool { return obj.(*Organization).Name == "fake" }) + assert.False(t, found) + assert.NoError(t, err) +} + +func TestPagination_MultiplePages(t *testing.T) { + found, err := TraversePages(multipleOrganizationsPage, func(obj interface{}) bool { return obj.(*Organization).Name == "test1220" }) + assert.True(t, found) + assert.NoError(t, err) + assert.Equal(t, 3, numberOfPagesTraversed) + + found, err = TraversePages(multipleOrganizationsPage, func(obj interface{}) bool { return obj.(*Organization).Name == "test1400" }) + assert.False(t, found) + assert.NoError(t, err) +} + +func TestPagination_Error(t *testing.T) { + _, err := TraversePages(func(pageNum int) (Paginated, error) { return nil, errors.New("Error!") }, + func(obj interface{}) bool { return obj.(*Organization).Name == "test1220" }) + assert.Errorf(t, err, "Error!") +} + +var singleOrganizationsPage = func(pageNum int) (Paginated, error) { + if pageNum == 1 { + // Note, that we don't specify 'next' attribute, so no extra pages will be requested + return &OrganizationsResponse{ + OMPaginated: OMPaginated{TotalCount: 1}, + Organizations: []*Organization{{ID: "1323", Name: "test"}}, + }, nil + } + return nil, errors.New("Not found!") +} + +var numberOfPagesTraversed = 0 + +var multipleOrganizationsPage = func(pageNum int) (Paginated, error) { + numberOfPagesTraversed++ + // page 1 + switch pageNum { + case 1: + return &OrganizationsResponse{ + OMPaginated: OMPaginated{TotalCount: 1300, Links: []*Link{{Rel: "next"}}}, + Organizations: generateOrganizations(0, 500), + }, nil + case 2: + return &OrganizationsResponse{ + OMPaginated: OMPaginated{TotalCount: 1300, Links: []*Link{{Rel: "next"}}}, + Organizations: generateOrganizations(500, 500), + }, nil + case 3: + return &OrganizationsResponse{ + OMPaginated: OMPaginated{TotalCount: 1300}, + Organizations: generateOrganizations(1000, 300), + }, nil + } + return nil, errors.New("Not found!") +} + +func generateOrganizations(startFrom, count int) []*Organization { + ans := make([]*Organization, count) + c := startFrom + for i := 0; i < count; i++ { + ans[i] = &Organization{ID: fmt.Sprintf("id%d", c), Name: fmt.Sprintf("test%d", c)} + c++ + } + return ans +} diff --git a/controllers/om/organization.go b/controllers/om/organization.go new file mode 100644 index 000000000..c174a18b3 --- /dev/null +++ b/controllers/om/organization.go @@ -0,0 +1,22 @@ +package om + +// OrganizationsResponse +type OrganizationsResponse struct { + OMPaginated + Organizations []*Organization `json:"results"` +} + +func (o OrganizationsResponse) Results() []interface{} { + // Lack of covariance in Go... :( + ans := make([]interface{}, len(o.Organizations)) + for i, org := range o.Organizations { + ans[i] = org + } + return ans +} + +// Organizations +type Organization struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` +} diff --git a/controllers/om/process.go b/controllers/om/process.go new file mode 100644 index 000000000..a29cdcead --- /dev/null +++ b/controllers/om/process.go @@ -0,0 +1,608 @@ +package om + +import ( + "encoding/json" + "fmt" + "path" + "strings" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/pkg/tls" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/util/maputil" + "github.com/blang/semver" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/automationconfig" + "github.com/spf13/cast" + "go.uber.org/zap" +) + +// MongoType refers to the type of the Mongo process, `mongos` or `mongod`. +type MongoType string + +const ( + // ProcessTypeMongos defines a constant for the mongos process type + ProcessTypeMongos MongoType = "mongos" + + // ProcessTypeMongod defines a constant for the mongod process type. + ProcessTypeMongod MongoType = "mongod" +) + +/* +This is a class for all types of processes. +Note, that mongos types of processes don't have some fields (replication, storage etc) but it's impossible to use a +separate types for different processes (mongos, mongod) with different methods due to limitation of Go embedding model. +So the code using this type must be careful and make sure the state is consistent. + +Dev notes: +- any new configurations must be "mirrored" in 'mergeFrom' method which merges the "operator owned" fields into +the process that was read from Ops Manager. +- the main principle used everywhere in 'om' code: the Operator overrides only the configurations it "owns" but leaves +the other properties unmodified. That's why structs are not used anywhere as they would result in possible overriding of +the whole elements which we don't want. Deal with data as with maps, create convenience methods (setters, getters, +ensuremap etc) and make sure not to override anything unrelated. + +The resulting json for this type (example): + + { + "args2_6": { + "net": { + "port": 28002, + "ssl": { + "mode": "requireSSL", + "PEMKeyFile": "/mongodb-automation/server.pem" + "clusterAuthFile: "/mongodb-automation/clusterfile.pem" + } + }, + "security" { + "clusterAuthMode":"x509" + } + "replication": { + "replSetName": "blue" + }, + "storage": { + "dbPath": "/data/blue_2", + "wiredTiger": { + "engineConfig": { + "cacheSizeGB": 0.3 + } + } + }, + "systemLog": { + "destination": "file", + "path": "/data/blue_2/mongodb.log" + } + }, + "hostname": "AGENT_HOSTNAME", + "logRotate": { + "sizeThresholdMB": 1000, + "timeThresholdHrs": 24 + }, + "name": "blue_2", + "processType": "mongod", + "version": "3.0.1", + "authSchemaVersion": 3 + } +*/ + +// used to indicate standalones when a type is used as an identifier +type Standalone map[string]interface{} + +// Process +type Process map[string]interface{} + +// NewProcessFromInterface +func NewProcessFromInterface(i interface{}) Process { + return i.(map[string]interface{}) +} + +// NewMongosProcess +func NewMongosProcess(name, hostName string, additionalMongodConfig *mdbv1.AdditionalMongodConfig, spec mdbv1.DbSpec, certificateFilePath string) Process { + if additionalMongodConfig == nil { + additionalMongodConfig = mdbv1.NewEmptyAdditionalMongodConfig() + } + + p := createProcess( + WithName(name), + WithHostname(hostName), + WithProcessType(ProcessTypeMongos), + WithAdditionalMongodConfig(*additionalMongodConfig), + WithResourceSpec(spec), + ) + + // default values for configurable values + p.SetLogPath(path.Join(util.PvcMountPathLogs, "/mongodb.log")) + if certificateFilePath == "" { + certificateFilePath = util.PEMKeyFilePathInContainer + } + + p.ConfigureTLS(getTLSMode(spec, *additionalMongodConfig), certificateFilePath) + + return p +} + +// NewMongodProcess +func NewMongodProcess(idx int, name, hostName string, additionalConfig *mdbv1.AdditionalMongodConfig, spec mdbv1.DbSpec, certificateFilePath string) Process { + if additionalConfig == nil { + additionalConfig = mdbv1.NewEmptyAdditionalMongodConfig() + } + + p := createProcess( + WithName(name), + WithHostname(hostName), + WithProcessType(ProcessTypeMongod), + WithAdditionalMongodConfig(*additionalConfig), + WithResourceSpec(spec), + ) + + // default values for configurable values + p.SetDbPath("/data") + // CLOUDP-33467: we put mongod logs to the same directory as AA/Monitoring/Backup ones to provide single mount point + // for all types of logs + p.SetLogPath(path.Join(util.PvcMountPathLogs, "mongodb.log")) + if certificateFilePath == "" { + certificateFilePath = util.PEMKeyFilePathInContainer + } + p.ConfigureTLS(getTLSMode(spec, *additionalConfig), certificateFilePath) + + return p +} + +func getTLSMode(spec mdbv1.DbSpec, additionalMongodConfig mdbv1.AdditionalMongodConfig) tls.Mode { + if !spec.IsSecurityTLSConfigEnabled() { + return tls.Disabled + } + return tls.GetTLSModeFromMongodConfig(additionalMongodConfig.ToMap()) + +} + +// DeepCopy +func (p Process) DeepCopy() (Process, error) { + return util.MapDeepCopy(p) +} + +// Name returns the name of the process. +func (p Process) Name() string { + return p["name"].(string) +} + +// HostName returns the hostname for this process. +func (p Process) HostName() string { + return p["hostname"].(string) +} + +// GetVotes returns the number of votes requested for the member using this process. +func (p Process) GetVotes() int { + if votes, ok := p["votes"]; ok { + return cast.ToInt(votes) + } + return 1 +} + +// GetPriority returns the requested priority for the member using this process. +func (p Process) GetPriority() float32 { + if priority, ok := p["priority"]; ok { + return cast.ToFloat32(priority) + } + return 1.0 +} + +// GetTags returns the requested tags for the member using this process. +func (p Process) GetTags() map[string]string { + if tags, ok := p["tags"]; ok { + tagMap, ok := tags.(map[string]interface{}) + if !ok { + return nil + } + result := make(map[string]string) + for k, v := range tagMap { + result[k] = cast.ToString(v) + } + return result + } + return nil +} + +// SetDbPath sets the DbPath for this process. +func (p Process) SetDbPath(dbPath string) Process { + util.ReadOrCreateMap(p.Args(), "storage")["dbPath"] = dbPath + return p +} + +// DbPath returns the DbPath for this process. +func (p Process) DbPath() string { + return maputil.ReadMapValueAsString(p.Args(), "storage", "dbPath") +} + +// SetWiredTigerCache +func (p Process) SetWiredTigerCache(cacheSizeGb float32) Process { + if p.ProcessType() != ProcessTypeMongod { + // WiredTigerCache can be set only for mongod processes + return p + } + storageMap := util.ReadOrCreateMap(p.Args(), "storage") + wiredTigerMap := util.ReadOrCreateMap(storageMap, "wiredTiger") + engineConfigMap := util.ReadOrCreateMap(wiredTigerMap, "engineConfig") + engineConfigMap["cacheSizeGB"] = cacheSizeGb + return p +} + +// WiredTigerCache returns wired tiger cache as pointer as it may be absent +func (p Process) WiredTigerCache() *float32 { + value := maputil.ReadMapValueAsInterface(p.Args(), "storage", "wiredTiger", "engineConfig", "cacheSizeGB") + if value == nil { + return nil + } + f := cast.ToFloat32(value) + return &f +} + +// SetLogPath +func (p Process) SetLogPath(logPath string) Process { + sysLogMap := util.ReadOrCreateMap(p.Args(), "systemLog") + sysLogMap["destination"] = "file" + sysLogMap["path"] = logPath + return p +} + +// LogPath +func (p Process) LogPath() string { + return maputil.ReadMapValueAsString(p.Args(), "systemLog", "path") +} + +// Args returns the "args" attribute in the form of a map, creates if it doesn't exist +func (p Process) Args() map[string]interface{} { + return util.ReadOrCreateMap(p, "args2_6") +} + +// Version of the process. This refers to the MongoDB server version. +func (p Process) Version() string { + return p["version"].(string) +} + +// ProcessType returs the type of process for the current process. +// It can be `mongos` or `mongod`. +func (p Process) ProcessType() MongoType { + switch v := p["processType"].(type) { + case string: + return MongoType(v) + case MongoType: + return v + default: + panic(fmt.Sprintf("Unexpected type of processType variable: %T", v)) + } +} + +// IsDisabled returns the "disabled" attribute. +func (p Process) IsDisabled() bool { + return p["disabled"].(bool) +} + +// SetDisabled sets the "disabled" attribute to `disabled`. +func (p Process) SetDisabled(disabled bool) { + p["disabled"] = disabled +} + +// EnsureNetConfig returns the Net configuration map ("net"), creates an empty map if it didn't exist +func (p Process) EnsureNetConfig() map[string]interface{} { + return util.ReadOrCreateMap(p.Args(), "net") +} + +// EnsureTLSConfig returns the TLS configuration map ("net.tls"), creates an empty map if it didn't exist. +// Use this method if you intend to make updates to the map returned +func (p Process) EnsureTLSConfig() map[string]interface{} { + netConfig := p.EnsureNetConfig() + return util.ReadOrCreateMap(netConfig, "tls") +} + +// SSLConfig returns the TLS configuration map ("net.tls") or an empty map if it doesn't exist. +// Use this method only to read values, not update +func (p Process) TLSConfig() map[string]interface{} { + netConfig := p.EnsureNetConfig() + if _, ok := netConfig["tls"]; ok { + return netConfig["tls"].(map[string]interface{}) + } + + return make(map[string]interface{}) +} + +func (p Process) EnsureSecurity() map[string]interface{} { + return util.ReadOrCreateMap(p.Args(), "security") +} + +// ConfigureClusterAuthMode sets the cluster auth mode for the process. +// Only accepted value for now is X509. +// internalClusterAuth is a parameter that overrides where the cert is located. +// If provided with an empty string, the operator will set it to +// a concatenation of the default mount path and the name of the process-pem +func (p Process) ConfigureClusterAuthMode(clusterAuthMode string, internalClusterPath string) Process { + if strings.ToUpper(clusterAuthMode) == util.X509 { // Ops Manager value is "x509" + // the individual key per pod will be podname-pem e.g. my-replica-set-0-pem + p.setClusterAuthMode("x509") + clusterFile := fmt.Sprintf("%s%s-pem", util.InternalClusterAuthMountPath, p.Name()) + if internalClusterPath != "" { + clusterFile = internalClusterPath + } + p.setClusterFile(clusterFile) + } + return p +} + +func (p Process) IsTLSEnabled() bool { + _, keyFile0 := p.TLSConfig()["PEMKeyFile"] + _, keyFile1 := p.TLSConfig()["certificateKeyFile"] + + return keyFile0 || keyFile1 +} + +func (p Process) HasInternalClusterAuthentication() bool { + return p.ClusterAuthMode() != "" +} + +func (p Process) FeatureCompatibilityVersion() string { + if p["featureCompatibilityVersion"] == nil { + return "" + } + return p["featureCompatibilityVersion"].(string) +} + +func (p Process) Alias() string { + if alias, ok := p["alias"].(string); ok { + return alias + } + + return "" +} + +// String +func (p Process) String() string { + return fmt.Sprintf("\"%s\" (hostName: %s, version: %s, args: %s)", p.Name(), p.HostName(), p.Version(), p.Args()) +} + +// ****************** These ones are private methods not exposed to other packages ************************************* + +// createProcess initializes a process. It's a common initialization done for both mongos and mongod processes +func createProcess(opts ...ProcessOption) Process { + process := Process{} + for _, opt := range opts { + opt(process) + } + return process +} + +type ProcessOption func(process Process) + +func WithResourceSpec(resourceSpec mdbv1.DbSpec) ProcessOption { + return func(process Process) { + processVersion := resourceSpec.GetMongoDBVersion() + process["version"] = processVersion + process["authSchemaVersion"] = CalculateAuthSchemaVersion(processVersion) + featureCompatibilityVersion := resourceSpec.GetFeatureCompatibilityVersion() + if featureCompatibilityVersion == nil { + computedFcv := calculateFeatureCompatibilityVersion(processVersion) + featureCompatibilityVersion = &computedFcv + } + process["featureCompatibilityVersion"] = *featureCompatibilityVersion + } +} + +func WithName(name string) ProcessOption { + return func(process Process) { + process["name"] = name + } +} + +func WithHostname(hostname string) ProcessOption { + return func(process Process) { + process["hostname"] = hostname + } +} + +func WithProcessType(processType MongoType) ProcessOption { + return func(process Process) { + process["processType"] = processType + } +} + +func WithMemberOptions(memberOptions automationconfig.MemberOptions) ProcessOption { + return func(process Process) { + if memberOptions.Votes != nil { + process["votes"] = cast.ToInt(memberOptions.Votes) + } + if memberOptions.Priority != nil { + process["priority"] = cast.ToFloat32(memberOptions.Priority) + } + process["tags"] = memberOptions.Tags + } +} + +func WithAdditionalMongodConfig(additionalConfig mdbv1.AdditionalMongodConfig) ProcessOption { + return func(process Process) { + // Applying the user-defined options if any + process["args2_6"] = additionalConfig.ToMap() + process.EnsureNetConfig()["port"] = additionalConfig.GetPortOrDefault() + } +} + +func WithProcessAlias(idx int, aliases []string) ProcessOption { + return func(process Process) { + if aliases != nil && idx < len(aliases) { + process["alias"] = aliases[idx] + } + } +} + +// ConfigureTLS enable TLS for this process. TLS will always be enabled after calling this. This function expects +// the value of "mode" to be an allowed ssl.mode from OM API perspective. +func (p Process) ConfigureTLS(mode tls.Mode, pemKeyFileLocation string) { + // Initializing SSL configuration if it's necessary + tlsConfig := p.EnsureTLSConfig() + tlsConfig["mode"] = string(mode) + + if mode == tls.Disabled { + // If these attribute exists, it needs to be removed + // PEMKeyFile is older + // certificateKeyFile is the current one + delete(tlsConfig, "certificateKeyFile") + delete(tlsConfig, "PEMKeyFile") + } else { + // PEMKeyFile is the legacy option found under net.ssl, deprecated since version 4.2 + // https://www.mongodb.com/docs/manual/reference/configuration-options/#mongodb-setting-net.ssl.PEMKeyFile + _, oldKeyInConfig := tlsConfig["PEMKeyFile"] + // certificateKeyFile is the current option under net.tls + // https://www.mongodb.com/docs/manual/reference/configuration-options/#mongodb-setting-net.tls.certificateKeyFile + _, newKeyInConfig := tlsConfig["certificateKeyFile"] + + // If both options are present in the TLS config we only want to keep the recent option. The problem + // can be encountered when migrating the operator from an older version which pushed an automation config + // containing the old key and the new operator is attempting to configure the new key. + if oldKeyInConfig == newKeyInConfig { + tlsConfig["certificateKeyFile"] = pemKeyFileLocation + delete(tlsConfig, "PEMKeyFile") + } else if newKeyInConfig { + tlsConfig["certificateKeyFile"] = pemKeyFileLocation + } else { + tlsConfig["PEMKeyFile"] = pemKeyFileLocation + } + } +} + +func calculateFeatureCompatibilityVersion(version string) string { + v1, err := semver.Make(version) + if err != nil { + zap.S().Warnf("Failed to parse version %s: %s", version, err) + return "" + } + + baseVersion, _ := semver.Make("3.4.0") + if v1.GTE(baseVersion) { + ans, _ := util.MajorMinorVersion(version) + return ans + } + + return "" +} + +// see https://github.com/10gen/ops-manager-kubernetes/pull/68#issuecomment-397247337 +func CalculateAuthSchemaVersion(version string) int { + v, err := semver.Make(version) + if err != nil { + zap.S().Warnf("Failed to parse version %s: %s", version, err) + return 5 + } + + baseVersion, _ := semver.Make("3.0.0") + if v.GTE(baseVersion) { + // Version >= 3.0 + return 5 + } + + // Version 2.6 + return 3 +} + +// mergeFrom merges the Operator version of process ('operatorProcess') into OM one ('p'). +// Considers the type of process and rewrites only relevant fields +func (p Process) mergeFrom(operatorProcess Process, specArgs26, prevArgs26 map[string]interface{}) { + // Dev note: merging the maps overrides/add map keys+value but doesn't remove the existing ones + // If there are any keys that need to be removed explicitly (to ensure OM changes haven't sneaked through) + // this must be done manually + maputil.MergeMaps(p, operatorProcess) + + p["args2_6"] = maputil.RemoveFieldsBasedOnDesiredAndPrevious(p.Args(), specArgs26, prevArgs26) + + // Merge SSL configuration (update if it's specified - delete otherwise) + if mode, ok := operatorProcess.TLSConfig()["mode"]; ok { + tlsConfig := p.EnsureTLSConfig() + for key, value := range operatorProcess.TLSConfig() { + tlsConfig[key] = value + } + // PEMKeyFile is the legacy option found under net.ssl, deprecated since version 4.2 + // https://www.mongodb.com/docs/manual/reference/configuration-options/#mongodb-setting-net.ssl.PEMKeyFile + _, oldKeyInConfig := tlsConfig["PEMKeyFile"] + // certificateKeyFile is the current option under net.tls + // https://www.mongodb.com/docs/manual/reference/configuration-options/#mongodb-setting-net.tls.certificateKeyFile + _, newKeyInConfig := tlsConfig["certificateKeyFile"] + // If both options are present in the TLS config we only want to keep the recent option. + if oldKeyInConfig && newKeyInConfig { + delete(tlsConfig, "PEMKeyFile") + } + // if the mode is specified as disabled, providing "PEMKeyFile" is an invalid config + if mode == string(tls.Disabled) { + delete(tlsConfig, "PEMKeyFile") + delete(tlsConfig, "certificateKeyFile") + } + } else { + delete(p.EnsureNetConfig(), "tls") + } +} + +func (p Process) setName(name string) Process { + p["name"] = name + return p +} + +func (p Process) setClusterFile(filePath string) Process { + p.EnsureTLSConfig()["clusterFile"] = filePath + return p +} + +func (p Process) setClusterAuthMode(authMode string) Process { + p.EnsureSecurity()["clusterAuthMode"] = authMode + return p +} + +func (p Process) authSchemaVersion() int { + return p["authSchemaVersion"].(int) +} + +// These methods are ONLY FOR REPLICA SET members! +// external packages are not supposed to call this method directly as it should be called during replica set building +func (p Process) setReplicaSetName(rsName string) Process { + util.ReadOrCreateMap(p.Args(), "replication")["replSetName"] = rsName + return p +} + +func (p Process) replicaSetName() string { + return maputil.ReadMapValueAsString(p.Args(), "replication", "replSetName") +} + +func (p Process) security() map[string]interface{} { + args := p.Args() + if _, ok := args["security"]; ok { + return args["security"].(map[string]interface{}) + } + return make(map[string]interface{}) +} + +func (p Process) ClusterAuthMode() string { + if authMode, ok := p.security()["clusterAuthMode"]; ok { + return authMode.(string) + } + return "" +} + +// These methods are ONLY FOR CONFIG SERVER REPLICA SET members! +// external packages are not supposed to call this method directly as it should be called during sharded cluster merge +func (p Process) setClusterRoleConfigSrv() Process { + util.ReadOrCreateMap(p.Args(), "sharding")["clusterRole"] = "configsvr" + return p +} + +// These methods are ONLY FOR MONGOS types! +// external packages are not supposed to call this method directly as it should be called during sharded cluster building +func (p Process) setCluster(clusterName string) Process { + p["cluster"] = clusterName + return p +} + +func (p Process) cluster() string { + return p["cluster"].(string) +} + +func (p Process) json() string { + b, err := json.MarshalIndent(p, "", " ") + if err != nil { + fmt.Println("error:", err) + } + return string(b) +} diff --git a/controllers/om/process/om_process.go b/controllers/om/process/om_process.go new file mode 100644 index 000000000..9616f1b08 --- /dev/null +++ b/controllers/om/process/om_process.go @@ -0,0 +1,75 @@ +package process + +import ( + "fmt" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/certs" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + mdbmultiv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdbmulti" + omv1 "github.com/10gen/ops-manager-kubernetes/api/v1/om" + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/pkg/dns" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + appsv1 "k8s.io/api/apps/v1" +) + +func CreateMongodProcessesWithLimit(set appsv1.StatefulSet, dbSpec mdbv1.DbSpec, limit int) []om.Process { + hostnames, names := dns.GetDnsForStatefulSetReplicasSpecified(set, dbSpec.GetClusterDomain(), limit, dbSpec.GetExternalDomain()) + processes := make([]om.Process, len(hostnames)) + + certificateFileName := "" + if certificateHash, ok := set.Annotations[certs.CertHashAnnotationKey]; ok { + certificateFileName = fmt.Sprintf("%s/%s", util.TLSCertMountPath, certificateHash) + } + + for idx, hostname := range hostnames { + processes[idx] = om.NewMongodProcess(idx, names[idx], hostname, dbSpec.GetAdditionalMongodConfig(), dbSpec, certificateFileName) + } + + return processes +} + +// CreateMongodProcessesWithLimitMulti creates the process array for automationConfig based on MultiCluster CR spec +func CreateMongodProcessesWithLimitMulti(mrs mdbmultiv1.MongoDBMultiCluster, certFileName string) ([]om.Process, error) { + hostnames := make([]string, 0) + clusterNums := make([]int, 0) + podNum := make([]int, 0) + clusterSpecList, err := mrs.GetClusterSpecItems() + if err != nil { + return nil, err + } + + for _, spec := range clusterSpecList { + agentHostNames := dns.GetMultiClusterAgentHostnames(mrs.Name, mrs.Namespace, mrs.ClusterNum(spec.ClusterName), spec.Members, spec.ExternalAccessConfiguration.ExternalDomain) + hostnames = append(hostnames, agentHostNames...) + for i := 0; i < len(agentHostNames); i++ { + clusterNums = append(clusterNums, mrs.ClusterNum(spec.ClusterName)) + podNum = append(podNum, i) + } + } + + processes := make([]om.Process, len(hostnames)) + for idx := range hostnames { + processes[idx] = om.NewMongodProcess(idx, fmt.Sprintf("%s-%d-%d", mrs.Name, clusterNums[idx], podNum[idx]), hostnames[idx], mrs.Spec.GetAdditionalMongodConfig(), &mrs.Spec, certFileName) + } + + return processes, nil +} + +func CreateAppDBProcesses(set appsv1.StatefulSet, mongoType om.MongoType, + mdb omv1.AppDBSpec) []om.Process { + + hostnames, names := dns.GetDnsForStatefulSet(set, mdb.GetClusterDomain(), nil) + processes := make([]om.Process, len(hostnames)) + + if mongoType != om.ProcessTypeMongod { + panic("Dev error: Wrong process type passed!") + } + + for idx, hostname := range hostnames { + processes[idx] = om.NewMongodProcessAppDB(names[idx], hostname, &mdb) + } + + return processes +} diff --git a/controllers/om/process_test.go b/controllers/om/process_test.go new file mode 100644 index 000000000..dd96224ed --- /dev/null +++ b/controllers/om/process_test.go @@ -0,0 +1,320 @@ +package om + +import ( + "fmt" + "testing" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/stretchr/testify/assert" + + "github.com/10gen/ops-manager-kubernetes/pkg/tls" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/util/maputil" +) + +func TestCreateMongodProcess(t *testing.T) { + t.Run("Create Mongod", func(t *testing.T) { + spec := defaultMongoDBVersioned("4.0.5") + process := NewMongodProcess(0, "trinity", "trinity-0.trinity-svc.svc.cluster.local", spec.GetAdditionalMongodConfig(), spec, "") + + assert.Equal(t, "trinity", process.Name()) + assert.Equal(t, "trinity-0.trinity-svc.svc.cluster.local", process.HostName()) + assert.Equal(t, "4.0.5", process.Version()) + assert.Equal(t, "4.0", process.FeatureCompatibilityVersion()) + assert.Equal(t, "/data", process.DbPath()) + assert.Equal(t, "/var/log/mongodb-mms-automation/mongodb.log", process.LogPath()) + assert.Equal(t, 5, process.authSchemaVersion()) + assert.Equal(t, "", process.replicaSetName()) + + expectedMap := map[string]interface{}{"port": int32(util.MongoDbDefaultPort), "tls": map[string]interface{}{ + "mode": "disabled", + }} + assert.Equal(t, expectedMap, process.EnsureNetConfig()) + }) + t.Run("Create with Mongodb options", func(t *testing.T) { + config := mdbv1.NewAdditionalMongodConfig("storage.engine", "inMemory"). + AddOption("setParameter.connPoolMaxConnsPerHost", 500). + AddOption("storage.dbPath", "/some/other/data") // this will be overridden + rs := mdbv1.NewReplicaSetBuilder().SetAdditionalConfig(config).Build() + + process := NewMongodProcess(0, "trinity", "trinity-0.trinity-svc.svc.cluster.local", rs.Spec.AdditionalMongodConfig, rs.GetSpec(), "") + + assert.Equal(t, "inMemory", maputil.ReadMapValueAsInterface(process.Args(), "storage", "engine")) + assert.Equal(t, 500, maputil.ReadMapValueAsInterface(process.Args(), "setParameter", "connPoolMaxConnsPerHost")) + assert.Equal(t, "/data", process.DbPath()) + }) +} + +func TestCreateMongodProcess_authSchemaVersion(t *testing.T) { + process := NewMongodProcess(0, "trinity", "trinity-0.trinity-svc.svc.cluster.local", &mdbv1.AdditionalMongodConfig{}, defaultMongoDBVersioned("2.6.2"), "") + assert.Equal(t, 3, process.authSchemaVersion()) + + process = NewMongodProcess(0, "trinity", "trinity-0.trinity-svc.svc.cluster.local", &mdbv1.AdditionalMongodConfig{}, defaultMongoDBVersioned("aaaa"), "") + assert.Equal(t, 5, process.authSchemaVersion()) + + process = NewMongodProcess(0, "trinity", "trinity-0.trinity-svc.svc.cluster.local", &mdbv1.AdditionalMongodConfig{}, defaultMongoDBVersioned("4.0.0"), "") + assert.Equal(t, 5, process.authSchemaVersion()) +} + +func TestCreateMongodProcess_featureCompatibilityVersion(t *testing.T) { + process := NewMongodProcess(0, "trinity", "trinity-0.trinity-svc.svc.cluster.local", &mdbv1.AdditionalMongodConfig{}, defaultMongoDBVersioned("3.0.6"), "") + assert.Equal(t, "", process.FeatureCompatibilityVersion()) + + process = NewMongodProcess(0, "trinity", "trinity-0.trinity-svc.svc.cluster.local", &mdbv1.AdditionalMongodConfig{}, defaultMongoDBVersioned("3.2.0"), "") + assert.Equal(t, "", process.FeatureCompatibilityVersion()) + + process = NewMongodProcess(0, "trinity", "trinity-0.trinity-svc.svc.cluster.local", &mdbv1.AdditionalMongodConfig{}, defaultMongoDBVersioned("aaa"), "") + assert.Equal(t, "", process.FeatureCompatibilityVersion()) + + mdb := mdbv1.NewStandaloneBuilder().SetVersion("4.2.1").SetFCVersion("4.0").Build() + process = NewMongodProcess(0, "trinity", "trinity-0.trinity-svc.svc.cluster.local", &mdbv1.AdditionalMongodConfig{}, mdb.GetSpec(), "") + assert.Equal(t, "4.0", process.FeatureCompatibilityVersion()) +} + +func TestConfigureSSL_Process(t *testing.T) { + process := Process{} + + process.ConfigureTLS(tls.Require, "pem-file0") + assert.Equal(t, map[string]interface{}{"mode": string(tls.Require), "certificateKeyFile": "pem-file0"}, process.TLSConfig()) + + process = Process{} + process.ConfigureTLS("", "pem-file1") + assert.Equal(t, map[string]interface{}{"mode": "", "certificateKeyFile": "pem-file1"}, process.TLSConfig()) + + process = Process{} + process.ConfigureTLS(tls.Disabled, "pem-file2") + assert.Equal(t, map[string]interface{}{"mode": string(tls.Disabled)}, process.TLSConfig()) +} + +func TestConfigureSSL_Process_CertificateKeyFile(t *testing.T) { + t.Run("When provided with certificateKeyFile attribute name, it is maintained", func(t *testing.T) { + process := Process{} + tlsConfig := process.EnsureTLSConfig() + tlsConfig["certificateKeyFile"] = "xxx" + process.ConfigureTLS(tls.Require, "pem-file0") + assert.Equal(t, map[string]interface{}{"mode": string(tls.Require), "certificateKeyFile": "pem-file0"}, process.TLSConfig()) + }) + + t.Run("A non-defined mode keeps the certificateKeyFile attribute name", func(t *testing.T) { + process := Process{} + tlsConfig := process.EnsureTLSConfig() + tlsConfig["certificateKeyFile"] = "xxx" + process.ConfigureTLS("", "pem-file1") + assert.Equal(t, map[string]interface{}{"mode": "", "certificateKeyFile": "pem-file1"}, process.TLSConfig()) + }) + + t.Run("If TLS is disabled, the certificateKeyFile attribute is deleted", func(t *testing.T) { + process := Process{} + tlsConfig := process.EnsureTLSConfig() + tlsConfig["certificateKeyFile"] = "xxx" + process.ConfigureTLS(tls.Disabled, "pem-file2") + assert.Equal(t, map[string]interface{}{"mode": string(tls.Disabled)}, process.TLSConfig()) + }) +} + +func TestTlsConfig(t *testing.T) { + process := Process{} + process.ConfigureTLS(tls.Require, "another-pem-file") + process.Args()["tls"] = map[string]interface{}{ + "mode": "requireTLS", + "PEMKeyFile": "another-pem-file", + } + + tlsConfig := process.TLSConfig() + assert.NotNil(t, tlsConfig) + assert.Equal(t, tlsConfig["mode"], "requireTLS") + assert.Equal(t, tlsConfig["certificateKeyFile"], "another-pem-file") +} + +func TestConfigureX509_Process(t *testing.T) { + mdb := &mdbv1.MongoDB{ + Spec: mdbv1.MongoDbSpec{ + DbCommonSpec: mdbv1.DbCommonSpec{ + Version: "3.6.4", + Security: &mdbv1.Security{ + Authentication: &mdbv1.Authentication{ + Modes: []string{util.X509}, + }, + }, + }, + }, + } + process := NewMongodProcess(0, "trinity", "trinity-0.trinity-svc.svc.cluster.local", &mdbv1.AdditionalMongodConfig{}, mdb.GetSpec(), "") + + process.ConfigureClusterAuthMode("", "") // should not update fields + assert.NotContains(t, process.security(), "clusterAuthMode") + assert.NotContains(t, process.TLSConfig(), "clusterFile") + + process.ConfigureClusterAuthMode(util.X509, "") // should update fields if specified as x509 + assert.Equal(t, "x509", process.security()["clusterAuthMode"]) + assert.Equal(t, fmt.Sprintf("%s%s-pem", util.InternalClusterAuthMountPath, process.Name()), process.TLSConfig()["clusterFile"]) +} + +func TestCreateMongodProcess_SSL(t *testing.T) { + additionalConfig := mdbv1.NewAdditionalMongodConfig("net.ssl.mode", string(tls.Prefer)) + + mdb := mdbv1.NewStandaloneBuilder().SetVersion("3.6.4").SetFCVersion("3.6").SetAdditionalConfig(additionalConfig).Build() + process := NewMongodProcess(0, "trinity", "trinity-0.trinity-svc.svc.cluster.local", additionalConfig, mdb.GetSpec(), "") + assert.Equal(t, map[string]interface{}{"mode": string(tls.Disabled)}, process.TLSConfig()) + + mdb = mdbv1.NewStandaloneBuilder().SetVersion("3.6.4").SetFCVersion("3.6").SetAdditionalConfig(additionalConfig). + SetSecurityTLSEnabled().Build() + + process = NewMongodProcess(0, "trinity", "trinity-0.trinity-svc.svc.cluster.local", additionalConfig, mdb.GetSpec(), "") + + assert.Equal(t, map[string]interface{}{"mode": string(tls.Prefer), + "certificateKeyFile": "/mongodb-automation/server.pem"}, process.TLSConfig()) +} + +func TestCreateMongosProcess_SSL(t *testing.T) { + additionalConfig := mdbv1.NewAdditionalMongodConfig("net.ssl.mode", string(tls.Allow)) + mdb := mdbv1.NewStandaloneBuilder().SetVersion("3.6.4").SetFCVersion("3.6").SetAdditionalConfig(additionalConfig). + SetSecurityTLSEnabled().Build() + process := NewMongosProcess("trinity", "trinity-0.trinity-svc.svc.cluster.local", additionalConfig, mdb.GetSpec(), "") + + assert.Equal(t, map[string]interface{}{"mode": string(tls.Allow), "certificateKeyFile": "/mongodb-automation/server.pem"}, process.TLSConfig()) +} + +func TestCreateMongodMongosProcess_TLSModeForDifferentSpecs(t *testing.T) { + assertTLSConfig := func(p Process) { + expectedMap := map[string]interface{}{ + "mode": string(tls.Allow), + "certificateKeyFile": "/mongodb-automation/server.pem", + } + assert.Equal(t, expectedMap, p.TLSConfig()) + } + + getSpec := func(builder *mdbv1.MongoDBBuilder) mdbv1.DbSpec { + return builder.SetSecurityTLSEnabled().Build().GetSpec() + } + + name := "name" + host := "host" + additionalConfig := mdbv1.NewAdditionalMongodConfig("net.tls.mode", string(tls.Allow)) + + // standalone spec + assertTLSConfig(NewMongodProcess(0, name, host, additionalConfig, getSpec(mdbv1.NewStandaloneBuilder()), "")) + + // replica set spec + assertTLSConfig(NewMongodProcess(0, name, host, additionalConfig, getSpec(mdbv1.NewReplicaSetBuilder()), "")) + + // sharded cluster spec + assertTLSConfig(NewMongosProcess(name, host, additionalConfig, getSpec(mdbv1.NewClusterBuilder()), "")) + assertTLSConfig(NewMongodProcess(0, name, host, additionalConfig, getSpec(mdbv1.NewClusterBuilder()), "")) +} + +// TestMergeMongodProcess_SSL verifies that merging for the process SSL settings keeps the Operator "owned" properties +// and doesn't overwrite the other Ops Manager initiated configuration +func TestMergeMongodProcess_SSL(t *testing.T) { + additionalConfig := mdbv1.NewAdditionalMongodConfig("net.ssl.mode", string(tls.Require)) + operatorMdb := mdbv1.NewStandaloneBuilder().SetVersion("3.6.4").SetFCVersion("3.6"). + SetAdditionalConfig(additionalConfig).SetSecurityTLSEnabled().Build() + + omMdb := mdbv1.NewStandaloneBuilder().SetVersion("3.6.4").SetFCVersion("3.6"). + SetAdditionalConfig(mdbv1.NewEmptyAdditionalMongodConfig()).Build() + + operatorProcess := NewMongodProcess(0, "trinity", "trinity-0.trinity-svc.svc.cluster.local", &mdbv1.AdditionalMongodConfig{}, operatorMdb.GetSpec(), "") + omProcess := NewMongodProcess(0, "trinity", "trinity-0.trinity-svc.svc.cluster.local", &mdbv1.AdditionalMongodConfig{}, omMdb.GetSpec(), "") + omProcess.EnsureTLSConfig()["mode"] = "allowTLS" // this will be overridden + omProcess.EnsureTLSConfig()["PEMKeyFile"] = "/var/mongodb/server.pem" // this will be overridden + omProcess.EnsureTLSConfig()["sslOnNormalPorts"] = "true" // this will be left as-is + omProcess.EnsureTLSConfig()["PEMKeyPassword"] = "qwerty" // this will be left as-is + + omProcess.mergeFrom(operatorProcess, nil, nil) + + expectedSSLConfig := map[string]interface{}{ + "mode": string(tls.Require), + "certificateKeyFile": "/mongodb-automation/server.pem", + "sslOnNormalPorts": "true", + "PEMKeyPassword": "qwerty", + } + assert.Equal(t, expectedSSLConfig, maputil.ReadMapValueAsInterface(omProcess, "args2_6", "net", "tls")) +} + +func TestMergeMongodProcess_MongodbOptions(t *testing.T) { + omMdb := mdbv1.NewStandaloneBuilder().SetAdditionalConfig( + mdbv1.NewAdditionalMongodConfig("storage.wiredTiger.engineConfig.cacheSizeGB", 3)).Build() + omProcess := NewMongodProcess(0, "trinity", "trinity-0.trinity-svc.svc.cluster.local", omMdb.Spec.AdditionalMongodConfig, omMdb.GetSpec(), "") + + operatorMdb := mdbv1.NewStandaloneBuilder().SetAdditionalConfig( + mdbv1.NewAdditionalMongodConfig("storage.wiredTiger.engineConfig.directoryForIndexes", "/some/dir")).Build() + operatorProcess := NewMongodProcess(0, "trinity", "trinity-0.trinity-svc.svc.cluster.local", operatorMdb.Spec.AdditionalMongodConfig, operatorMdb.GetSpec(), "") + + omProcess.mergeFrom(operatorProcess, nil, nil) + + expectedArgs := map[string]interface{}{ + "net": map[string]interface{}{ + "port": int32(27017), + "tls": map[string]interface{}{ + "mode": "disabled", + }, + }, + "storage": map[string]interface{}{ + "dbPath": "/data", + "wiredTiger": map[string]interface{}{ + "engineConfig": map[string]interface{}{ + "cacheSizeGB": 3, // This is the native OM configuration + "directoryForIndexes": "/some/dir", // This is the configuration set by MongoDB spec + }, + }, + }, + "systemLog": map[string]interface{}{ + "destination": "file", + "path": "/var/log/mongodb-mms-automation/mongodb.log", + }, + } + + assert.Equal(t, expectedArgs, omProcess.Args()) +} + +func TestMergeMongodProcess_AdditionalMongodConfig_CanBeRemoved(t *testing.T) { + + prevAdditionalConfig := mdbv1.NewEmptyAdditionalMongodConfig() + prevAdditionalConfig.AddOption("storage.wiredTiger.engineConfig.cacheSizeGB", 3) + prevAdditionalConfig.AddOption("some.other.option", "value") + prevAdditionalConfig.AddOption("some.other.option2", "value2") + + omMdb := mdbv1.NewStandaloneBuilder().SetAdditionalConfig(prevAdditionalConfig).Build() + omProcess := NewMongodProcess(0, "trinity", "trinity-0.trinity-svc.svc.cluster.local", omMdb.Spec.AdditionalMongodConfig, omMdb.GetSpec(), "") + + specAdditionalConfig := mdbv1.NewEmptyAdditionalMongodConfig() + // we are changing the cacheSize to 4 + specAdditionalConfig.AddOption("storage.wiredTiger.engineConfig.cacheSizeGB", 4) + // here we are simulating removing "some.other.option2" by not specifying it. + specAdditionalConfig.AddOption("some.other.option", "value") + + operatorMdb := mdbv1.NewStandaloneBuilder().SetAdditionalConfig(specAdditionalConfig).Build() + operatorProcess := NewMongodProcess(0, "trinity", "trinity-0.trinity-svc.svc.cluster.local", operatorMdb.Spec.AdditionalMongodConfig, operatorMdb.GetSpec(), "") + + omProcess.mergeFrom(operatorProcess, specAdditionalConfig.ToMap(), prevAdditionalConfig.ToMap()) + + args := omProcess.Args() + + expectedArgs := map[string]interface{}{ + "net": map[string]interface{}{ + "port": int32(27017), + "tls": map[string]interface{}{ + "mode": "disabled", + }, + }, + "storage": map[string]interface{}{ + "dbPath": "/data", + "wiredTiger": map[string]interface{}{ + "engineConfig": map[string]interface{}{ + "cacheSizeGB": 4, + }, + }, + }, + "systemLog": map[string]interface{}{ + "destination": "file", + "path": "/var/log/mongodb-mms-automation/mongodb.log", + }, + "some": map[string]interface{}{ + "other": map[string]interface{}{ + "option": "value", + }, + }, + } + + assert.Equal(t, expectedArgs, args, "option2 should have been removed as it was not specified") +} diff --git a/controllers/om/replicaset.go b/controllers/om/replicaset.go new file mode 100644 index 000000000..c72a32699 --- /dev/null +++ b/controllers/om/replicaset.go @@ -0,0 +1,335 @@ +package om + +import ( + "fmt" + "sort" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/automationconfig" + "github.com/spf13/cast" + "go.uber.org/zap" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/pkg/util" +) + +/* This corresponds to: + { + "_id": "blue", + "members": [ + { + "_id": 0, + "host": "blue_0" + }, + { + "_id": 1, + "host": "blue_1" + }, + { + "_id": 2, + "arbiterOnly": true, + "host": "blue_2", + "priority": 0 + } + ] +}*/ + +// ReplicaSet +type ReplicaSet map[string]interface{} + +/* This corresponds to: + { + "_id": 0, + "host": "blue_0", + "priority": 0, + "slaveDelay": 0 + }*/ + +// ReplicaSetMember +type ReplicaSetMember map[string]interface{} + +// NewReplicaSetFromInterface +func NewReplicaSetFromInterface(i interface{}) ReplicaSet { + return i.(map[string]interface{}) +} + +// NewReplicaSetMemberFromInterface +func NewReplicaSetMemberFromInterface(i interface{}) ReplicaSetMember { + return i.(map[string]interface{}) +} + +// NewReplicaSet +func NewReplicaSet(name, version string) ReplicaSet { + ans := ReplicaSet{} + ans["members"] = make([]ReplicaSetMember, 0) + + // "protocolVersion" was a new field in 3.2+ Mongodb + var protocolVersion string + compare, err := util.CompareVersions(version, "3.2.0") + if err != nil { + zap.S().Warnf("Failed to parse version %s: %s", version, err) + } else if compare >= 0 { + protocolVersion = "1" + } + + initDefaultRs(ans, name, protocolVersion) + + return ans +} + +func (r ReplicaSet) Name() string { + return r["_id"].(string) +} + +func (r ReplicaSetMember) Name() string { + return r["host"].(string) +} + +func (r ReplicaSetMember) Id() int { + // Practice shows that the type of unmarshalled data can be even float64 (no floating point though) or int32.. + return cast.ToInt(r["_id"]) +} + +func (r ReplicaSetMember) Votes() int { + return cast.ToInt(r["votes"]) +} + +func (r ReplicaSetMember) Priority() float32 { + return cast.ToFloat32(r["priority"]) +} + +func (r ReplicaSetMember) Tags() map[string]string { + return r["tags"].(map[string]string) +} + +/* Merges the other replica set to the current one. "otherRs" members have higher priority (as they are supposed + to be RS members managed by Kubernetes). + Returns the list of names of members which were removed as the result of merge (either they were added by mistake in OM + or we are scaling down) + + Example: + + Current RS: + + "members": [ + { + "_id": 0, + "host": "blue_0", + "arbiterOnly": true + }, + { + "_id": 1, + "host": "blue_1" + }] + + Other RS: + + "members": [ + { + "_id": 0, + "host": "green_0" + }, + { + "_id": 2, + "host": "green_2" + }] + + Merge result: + + "members": [ + { + "_id": 0, + "host": "green_0", + "arbiterOnly": true + }, + { + "_id": 2, + "host": "green_2" + }] +},*/ + +func (r ReplicaSet) String() string { + return fmt.Sprintf("\"%s\" (members: %v)", r.Name(), r.Members()) +} + +// ***************************************** Private methods *********************************************************** + +func initDefaultRs(set ReplicaSet, name string, protocolVersion string) { + if protocolVersion != "" { + // Automation Agent considers the cluster config with protocol version as string + set["protocolVersion"] = protocolVersion + } + set.setName(name) +} + +// Adding a member to the replicaset. The _id for the new member is calculated +// based on last existing member in the RS. +func (r ReplicaSet) addMember(process Process, id string, options automationconfig.MemberOptions) { + members := r.Members() + lastIndex := -1 + if len(members) > 0 { + lastIndex = members[len(members)-1].Id() + } + + rsMember := ReplicaSetMember{} + rsMember["_id"] = id + if id == "" { + rsMember["_id"] = lastIndex + 1 + } + rsMember["host"] = process.Name() + + // We always set this member to have vote (it will be set anyway on creation of deployment in OM), though this can + // be overriden by OM during merge and corrected in the end (as rs can have only 7 voting members) + rsMember.setVotes(options.GetVotes()).setPriority(options.GetPriority()).setTags(options.GetTags()) + r.setMembers(append(members, rsMember)) +} + +// mergeFrom merges "operatorRs" into "OM" one +func (r ReplicaSet) mergeFrom(operatorRs ReplicaSet) []string { + initDefaultRs(r, operatorRs.Name(), operatorRs.protocolVersion()) + + // technically we use "operatorMap" as the target map which will be used to update the members + // for the 'r' object + omMap := buildMapOfRsNodes(r) + operatorMap := buildMapOfRsNodes(operatorRs) + + // merge overlapping members into the operatorMap (overriding the 'host', + // 'horizons' and '_id' fields only) + for k, currentValue := range omMap { + if otherValue, ok := operatorMap[k]; ok { + currentValue["host"] = otherValue.Name() + currentValue["_id"] = otherValue.Id() + currentValue["votes"] = otherValue.Votes() + currentValue["priority"] = otherValue.Priority() + currentValue["tags"] = otherValue.Tags() + horizons := otherValue.getHorizonConfig() + if len(horizons) > 0 { + currentValue["horizons"] = horizons + } else { + delete(currentValue, "horizons") + } + operatorMap[k] = currentValue + } + } + + // find OM members that will be removed from RS. This can be either the result of scaling + // down or just OM added some members on its own + removedMembers := findDifference(omMap, operatorMap) + + // update replicaset back + replicas := make([]ReplicaSetMember, len(operatorMap)) + i := 0 + for _, v := range operatorMap { + replicas[i] = v + i++ + } + sort.Slice(replicas, func(i, j int) bool { + return replicas[i].Id() < replicas[j].Id() + }) + r.setMembers(replicas) + + return removedMembers +} + +// members returns all members of replica set. Note, that this should stay package-private as 'operator' package should +// not have direct access to members. +// The members returned are not copies and can be used direcly for mutations +func (r ReplicaSet) Members() []ReplicaSetMember { + switch v := r["members"].(type) { + case []ReplicaSetMember: + return v + case []interface{}: + ans := make([]ReplicaSetMember, len(v)) + for i, val := range v { + ans[i] = NewReplicaSetMemberFromInterface(val) + } + return ans + default: + panic("Unexpected type of members variable") + } +} + +func (r ReplicaSet) setName(name string) { + r["_id"] = name +} + +func (r ReplicaSet) setMembers(members []ReplicaSetMember) { + r["members"] = members +} + +func (r ReplicaSet) clearMembers() { + r["members"] = make([]ReplicaSetMember, 0) +} + +func (r ReplicaSet) findMemberByName(name string) *ReplicaSetMember { + members := r.Members() + for _, m := range members { + if m.Name() == name { + return &m + } + } + + return nil +} + +// mms uses string for this field to make it optional in json +func (r ReplicaSet) protocolVersion() string { + return r["protocolVersion"].(string) +} + +func (r ReplicaSetMember) getHorizonConfig() mdbv1.MongoDBHorizonConfig { + if horizons, okay := r["horizons"]; okay { + return horizons.(mdbv1.MongoDBHorizonConfig) + } + return mdbv1.MongoDBHorizonConfig{} +} + +func (r ReplicaSetMember) setHorizonConfig(horizonConfig mdbv1.MongoDBHorizonConfig) ReplicaSetMember { + // must not set empty horizon config + if len(horizonConfig) > 0 { + r["horizons"] = horizonConfig + } + + return r +} + +// Note, that setting vote to 0 without setting priority to the same value is not correct +func (r ReplicaSetMember) setVotes(votes int) ReplicaSetMember { + r["votes"] = votes + + return r +} + +func (r ReplicaSetMember) setPriority(priority float32) ReplicaSetMember { + r["priority"] = priority + + return r +} + +func (r ReplicaSetMember) setTags(tags map[string]string) ReplicaSetMember { + finalTags := make(map[string]string) + for k, v := range tags { + finalTags[k] = v + } + r["tags"] = finalTags + return r +} + +// Returns keys that exist in leftMap but don't exist in right one +func findDifference(leftMap map[string]ReplicaSetMember, rightMap map[string]ReplicaSetMember) []string { + ans := make([]string, 0) + for k := range leftMap { + if _, ok := rightMap[k]; !ok { + ans = append(ans, k) + } + } + return ans +} + +// Builds the map[]. This makes intersection easier +func buildMapOfRsNodes(rs ReplicaSet) map[string]ReplicaSetMember { + ans := make(map[string]ReplicaSetMember) + for _, r := range rs.Members() { + ans[r.Name()] = r + } + return ans +} diff --git a/controllers/om/replicaset/om_replicaset.go b/controllers/om/replicaset/om_replicaset.go new file mode 100644 index 000000000..e15ab977a --- /dev/null +++ b/controllers/om/replicaset/om_replicaset.go @@ -0,0 +1,109 @@ +package replicaset + +import ( + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + omv1 "github.com/10gen/ops-manager-kubernetes/api/v1/om" + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/controllers/om/process" + "github.com/10gen/ops-manager-kubernetes/pkg/dns" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/util/scale" + zap "go.uber.org/zap" + "golang.org/x/xerrors" + appsv1 "k8s.io/api/apps/v1" +) + +// BuildFromStatefulSet returns a replica set that can be set in the Automation Config +// based on the given StatefulSet and MongoDB resource. +func BuildFromStatefulSet(set appsv1.StatefulSet, dbSpec mdbv1.DbSpec) om.ReplicaSetWithProcesses { + return BuildFromStatefulSetWithReplicas(set, dbSpec, int(*set.Spec.Replicas)) +} + +// BuildFromStatefulSetWithReplicas returns a replica set that can be set in the Automation Config +// based on the given StatefulSet and MongoDB spec. The amount of members is set by the replicas +// parameter. +func BuildFromStatefulSetWithReplicas(set appsv1.StatefulSet, dbSpec mdbv1.DbSpec, replicas int) om.ReplicaSetWithProcesses { + members := process.CreateMongodProcessesWithLimit(set, dbSpec, replicas) + replicaSet := om.NewReplicaSet(set.Name, dbSpec.GetMongoDBVersion()) + rsWithProcesses := om.NewReplicaSetWithProcesses(replicaSet, members, dbSpec.GetMemberOptions()) + rsWithProcesses.SetHorizons(dbSpec.GetHorizonConfig()) + return rsWithProcesses +} + +// BuildAppDBFromStatefulSet builds replica set that will represent the AppDB +// based on the StatefulSet and AppDB provided. +func BuildAppDBFromStatefulSet(set appsv1.StatefulSet, mdb omv1.AppDBSpec) om.ReplicaSetWithProcesses { + members := process.CreateAppDBProcesses(set, om.ProcessTypeMongod, mdb) + replicaSet := om.NewReplicaSet(set.Name, mdb.GetMongoDBVersion()) + rsWithProcesses := om.NewReplicaSetWithProcesses(replicaSet, members, mdb.GetMemberOptions()) + return rsWithProcesses +} + +// PrepareScaleDownFromMap performs additional steps necessary to make sure removed members are not primary (so no +// election happens and replica set is available) (see +// https://jira.mongodb.org/browse/HELP-3818?focusedCommentId=1548348 for more details) +// Note, that we are skipping setting nodes as "disabled" (but the code is commented to be able to revert this if +// needed) +func PrepareScaleDownFromMap(omClient om.Connection, rsMembers map[string][]string, log *zap.SugaredLogger) error { + processes := make([]string, 0) + for _, v := range rsMembers { + processes = append(processes, v...) + } + + // Stage 1. Set Votes and Priority to 0 + if len(rsMembers) > 0 { + err := omClient.ReadUpdateDeployment( + func(d om.Deployment) error { + for k, v := range rsMembers { + if err := d.MarkRsMembersUnvoted(k, v); err != nil { + log.Errorf("Problems scaling down some replica sets (were they changed in Ops Manager directly?): %s", err) + } + } + return nil + }, + log, + ) + + if err != nil { + return xerrors.Errorf("unable to set votes, priority to 0 in Ops Manager, hosts: %v, err: %w", processes, err) + } + + if err := om.WaitForReadyState(omClient, processes, log); err != nil { + return err + } + + log.Debugw("Marked replica set members as non-voting", "replica set with members", rsMembers) + } + + // TODO practice shows that automation agents can get stuck on setting db to "disabled" also it seems that this process + // works correctly without explicit disabling - feel free to remove this code after some time when it is clear + // that everything works correctly without disabling + + // Stage 2. Set disabled to true + //err = omClient.ReadUpdateDeployment( + // func(d om.Deployment) error { + // d.DisableProcesses(allProcesses) + // return nil + // }, + //) + // + //if err != nil { + // return errors.New(fmt.Sprintf("Unable to set disabled to true, hosts: %v, err: %w", allProcesses, err)) + //} + //log.Debugw("Disabled processes", "processes", allProcesses) + + log.Infow("Performed some preliminary steps to support scale down", "hosts", processes) + + return nil +} + +func PrepareScaleDownFromStatefulSet(omClient om.Connection, statefulSet appsv1.StatefulSet, rs *mdbv1.MongoDB, log *zap.SugaredLogger) error { + _, podNames := dns.GetDnsForStatefulSetReplicasSpecified(statefulSet, rs.Spec.GetClusterDomain(), rs.Status.Members, nil) + podNames = podNames[scale.ReplicasThisReconciliation(rs):rs.Status.Members] + + if len(podNames) != 1 { + return xerrors.Errorf("dev error: the number of members being scaled down was > 1, scaling more than one member at a time is not possible! %s", podNames) + } + + log.Debugw("Setting votes to 0 for members", "members", podNames) + return PrepareScaleDownFromMap(omClient, map[string][]string{rs.Name: podNames}, log) +} diff --git a/controllers/om/replicaset/om_replicaset_test.go b/controllers/om/replicaset/om_replicaset_test.go new file mode 100644 index 000000000..c37aec93c --- /dev/null +++ b/controllers/om/replicaset/om_replicaset_test.go @@ -0,0 +1,30 @@ +package replicaset + +import ( + "testing" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + omv1 "github.com/10gen/ops-manager-kubernetes/api/v1/om" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/construct" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/mock" + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" + "github.com/stretchr/testify/assert" + zap "go.uber.org/zap" +) + +func init() { + logger, _ := zap.NewDevelopment() + zap.ReplaceGlobals(logger) + mock.InitDefaultEnvVariables() +} + +func TestBuildReplicaSetFromStatefulSetAppDb(t *testing.T) { + for i := 0; i < 10; i++ { + opsManager := omv1.NewOpsManagerBuilder().SetAppDbPodSpec(mdbv1.MongoDbPodSpec{}).Build() + opsManager.Spec.AppDB.Members = i + appDbSts, err := construct.AppDbStatefulSet(opsManager, &env.PodEnvVars{ProjectID: "abcd"}, construct.AppDBStatefulSetOptions{}, nil) + assert.NoError(t, err) + omRs := BuildAppDBFromStatefulSet(appDbSts, omv1.AppDBSpec{Version: "4.4.0"}) + assert.Len(t, omRs.Processes, i) + } +} diff --git a/controllers/om/replicaset_test.go b/controllers/om/replicaset_test.go new file mode 100644 index 000000000..026d5e07e --- /dev/null +++ b/controllers/om/replicaset_test.go @@ -0,0 +1,84 @@ +package om + +import ( + "strconv" + "testing" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/automationconfig" + "github.com/stretchr/testify/assert" +) + +func makeMinimalRsWithProcesses() ReplicaSetWithProcesses { + replicaSetWithProcesses := NewReplicaSet("my-test-repl", "4.2.1") + mdb := mdbv1.MongoDB{Spec: mdbv1.MongoDbSpec{DbCommonSpec: mdbv1.DbCommonSpec{Version: "4.2.1"}}} + mdb.InitDefaults() + var processes []Process = make([]Process, 3) + var memberOptions []automationconfig.MemberOptions = make([]automationconfig.MemberOptions, 3) + for i := range processes { + proc := NewMongodProcess(i, "my-test-repl-"+strconv.Itoa(i), "my-test-repl-"+strconv.Itoa(i), &mdbv1.AdditionalMongodConfig{}, &mdb.Spec, "") + processes[i] = proc + replicaSetWithProcesses.addMember(proc, "", memberOptions[i]) + } + return NewReplicaSetWithProcesses(replicaSetWithProcesses, processes, memberOptions) +} + +// TestMergeHorizonsAdd checks that horizon configuration is appropriately +// added. +func TestMergeHorizonsAdd(t *testing.T) { + opsManagerRsWithProcesses := makeMinimalRsWithProcesses() + operatorRsWithProcesses := makeMinimalRsWithProcesses() + horizons := []mdbv1.MongoDBHorizonConfig{ + {"name1": "my-db.my-test.com:12345"}, + {"name1": "my-db.my-test.com:12346"}, + {"name1": "my-db.my-test.com:12347"}, + } + operatorRsWithProcesses.SetHorizons(horizons) + + opsManagerRsWithProcesses.Rs.mergeFrom(operatorRsWithProcesses.Rs) + for i, member := range opsManagerRsWithProcesses.Rs.Members() { + assert.Equal(t, horizons[i], member.getHorizonConfig()) + } +} + +// TestMergeHorizonsIgnore checks that old horizon configuration is removed +// when merged with a replica set with no horizon configuration. +func TestMergeHorizonsRemove(t *testing.T) { + opsManagerRsWithProcesses := makeMinimalRsWithProcesses() + horizons := []mdbv1.MongoDBHorizonConfig{ + {"name1": "my-db.my-test.com:12345"}, + {"name1": "my-db.my-test.com:12346"}, + {"name1": "my-db.my-test.com:12347"}, + } + opsManagerRsWithProcesses.SetHorizons(horizons) + operatorRsWithProcesses := makeMinimalRsWithProcesses() + + opsManagerRsWithProcesses.Rs.mergeFrom(operatorRsWithProcesses.Rs) + for _, member := range opsManagerRsWithProcesses.Rs.Members() { + assert.Equal(t, mdbv1.MongoDBHorizonConfig{}, member.getHorizonConfig()) + } +} + +// TestMergeHorizonsOverride checks that new horizon configuration overrides +// old horizon configuration. +func TestMergeHorizonsOverride(t *testing.T) { + opsManagerRsWithProcesses := makeMinimalRsWithProcesses() + horizonsOld := []mdbv1.MongoDBHorizonConfig{ + {"name1": "my-db.my-test.com:12345"}, + {"name1": "my-db.my-test.com:12346"}, + {"name1": "my-db.my-test.com:12347"}, + } + horizonsNew := []mdbv1.MongoDBHorizonConfig{ + {"name2": "my-db.my-test.com:12345"}, + {"name2": "my-db.my-test.com:12346"}, + {"name2": "my-db.my-test.com:12347"}, + } + opsManagerRsWithProcesses.SetHorizons(horizonsOld) + operatorRsWithProcesses := makeMinimalRsWithProcesses() + operatorRsWithProcesses.SetHorizons(horizonsNew) + + opsManagerRsWithProcesses.Rs.mergeFrom(operatorRsWithProcesses.Rs) + for i, member := range opsManagerRsWithProcesses.Rs.Members() { + assert.Equal(t, horizonsNew[i], member.getHorizonConfig()) + } +} diff --git a/controllers/om/shardedcluster.go b/controllers/om/shardedcluster.go new file mode 100644 index 000000000..bd07f4637 --- /dev/null +++ b/controllers/om/shardedcluster.go @@ -0,0 +1,258 @@ +package om + +import ( + "sort" + + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" +) + +// Representation of one element in "sharding" array in OM json deployment: +/* +"sharding": [ + { + "shards": [ + { + "tags": [ + "gluetenfree" + ], + "_id": "electron_0", + "rs": "electron_0" + }, + { + "tags": [], + "_id": "electron_1", + "rs": "electron_1" + } + ], + "name": "electron", + "managedSharding": true, + "configServer": [], + "configServerReplica": "electroncsrs", + "collections": [ + { + "_id": "food.vegetables", + "dropped": false, + "key": [ + [ + "rand", + 1 + ] + ], + "unique": false + }, + { + "_id": "randomStuff.bigStuff", + "dropped": false, + "key": [ + [ + "x", + "hashed" + ] + ], + "unique": false + } + ], + "tags": [] + } + ] + } +*/ +type ShardedCluster map[string]interface{} + +type Shard map[string]interface{} + +func NewShardedClusterFromInterface(i interface{}) ShardedCluster { + return i.(map[string]interface{}) +} + +// NewShardedCluster builds a shard configuration with shards by replicasets names +func NewShardedCluster(name, configRsName string, replicaSets []ReplicaSetWithProcesses) ShardedCluster { + ans := ShardedCluster{} + ans.setName(name) + ans.setConfigServerRsName(configRsName) + + shards := make([]Shard, len(replicaSets)) + for k, v := range replicaSets { + s := newShard(v.Rs.Name()) + shards[k] = s + } + ans.setShards(shards) + return ans +} + +func (s ShardedCluster) Name() string { + return s["name"].(string) +} + +func (s ShardedCluster) ConfigServerRsName() string { + return s["configServerReplica"].(string) +} + +// ***************************************** Private methods *********************************************************** + +func newShard(name string) Shard { + s := Shard{} + s.setId(name) + s.setRs(name) + return s +} + +// mergeFrom merges the other (Kuberenetes owned) cluster configuration into OM one +func (s ShardedCluster) mergeFrom(operatorCluster ShardedCluster) []string { + s.setName(operatorCluster.Name()) + s.setConfigServerRsName(operatorCluster.ConfigServerRsName()) + + omMap := buildMapOfShards(s) + operatorMap := buildMapOfShards(operatorCluster) + + // merge overlapping members to the operatorMap + for k, currentValue := range omMap { + if otherValue, ok := operatorMap[k]; ok { + currentValue.mergeFrom(otherValue) + + operatorMap[k] = currentValue + } + } + + // find OM shards that will be removed from cluster. This can be either the result of shard cluster reconfiguration + // or just OM added some shards on its own + removedMembers := findDifferentKeys(omMap, operatorMap) + + // update cluster shards back + shards := make([]Shard, len(operatorMap)) + i := 0 + for _, v := range operatorMap { + shards[i] = v + i++ + } + sort.Slice(shards, func(i, j int) bool { + return shards[i].id() < shards[j].id() + }) + s.setShards(shards) + + return removedMembers +} + +// mergeFrom merges the operator shard into OM one. Only some fields are overriden, the others stay untouched +func (omShard Shard) mergeFrom(operatorShard Shard) { + omShard.setId(operatorShard.id()) + omShard.setRs(operatorShard.rs()) +} + +func (s ShardedCluster) shards() []Shard { + switch v := s["shards"].(type) { + case []Shard: + return v + case []interface{}: + ans := make([]Shard, len(v)) + for i, val := range v { + ans[i] = val.(map[string]interface{}) + } + return ans + default: + panic("Unexpected type of shards variable") + } +} + +func (s ShardedCluster) setConfigServerRsName(name string) { + s["configServerReplica"] = name +} + +func (s ShardedCluster) setName(name string) { + s["name"] = name +} + +func (s ShardedCluster) setShards(shards []Shard) { + s["shards"] = shards +} + +// draining returns the "draining" array which contains the names of replicasets for shards which are currently being +// removed. This is necessary for the AutomationAgent to keep the knowledge about shards to survive restarts (it's +// necessary to restart the mongod with the same 'shardSrv' option even if the shard is not in sharded cluster in +// Automation Config any more) +func (s ShardedCluster) draining() []string { + if _, ok := s["draining"]; !ok { + return make([]string, 0) + } + + // When go unmarhals an empty list from Json, it becomes + // []interface{} and not []string, so we must check for + // that particular case. + if obj, ok := s["draining"].([]interface{}); ok { + hostNames := []string{} + for _, hn := range obj { + hostNames = append(hostNames, hn.(string)) + } + + return hostNames + } + + return s["draining"].([]string) +} + +func (s ShardedCluster) setDraining(rsNames []string) { + s["draining"] = rsNames +} + +func (s ShardedCluster) addToDraining(rsNames []string) { + // constructor is a better place to initialize the array but we aim a better backward compatibility with OM 4.0 + // versions (which learnt about this field in 4.0.12) so doing lazy initialization + if _, ok := s["draining"]; !ok { + s.setDraining([]string{}) + } + for _, r := range rsNames { + if !stringutil.Contains(s.draining(), r) { + s["draining"] = append(s.draining(), r) + } + } +} + +func (s ShardedCluster) removeDraining() { + delete(s, "draining") +} + +// getAllReplicaSets returns all replica sets associated with sharded cluster +func (s ShardedCluster) getAllReplicaSets() []string { + ans := []string{} + for _, s := range s.shards() { + ans = append(ans, s.rs()) + } + ans = append(ans, s.ConfigServerRsName()) + return ans +} + +func (s Shard) id() string { + return s["_id"].(string) +} + +func (s Shard) setId(id string) { + s["_id"] = id +} + +func (s Shard) rs() string { + return s["rs"].(string) +} + +func (s Shard) setRs(rsName string) { + s["rs"] = rsName +} + +// Returns keys that exist in leftMap but don't exist in right one +func findDifferentKeys(leftMap map[string]Shard, rightMap map[string]Shard) []string { + ans := make([]string, 0) + for k := range leftMap { + if _, ok := rightMap[k]; !ok { + ans = append(ans, k) + } + } + return ans +} + +// Builds the map[]. This makes intersection easier +func buildMapOfShards(sh ShardedCluster) map[string]Shard { + ans := make(map[string]Shard) + for _, r := range sh.shards() { + ans[r.id()] = r + } + return ans +} diff --git a/controllers/om/test_utils.go b/controllers/om/test_utils.go new file mode 100644 index 000000000..c0aa3c247 --- /dev/null +++ b/controllers/om/test_utils.go @@ -0,0 +1,17 @@ +package om + +import ( + "fmt" + "io/ioutil" + "path/filepath" +) + +func loadBytesFromTestData(name string) []byte { + // testdata is a special directory ignored by "go build" + path := filepath.Join("testdata", name) + bytes, err := ioutil.ReadFile(path) + if err != nil { + fmt.Println(err) + } + return bytes +} diff --git a/controllers/om/testdata/automation_config.json b/controllers/om/testdata/automation_config.json new file mode 100644 index 000000000..e2e9041e8 --- /dev/null +++ b/controllers/om/testdata/automation_config.json @@ -0,0 +1,324 @@ +{ + "auth": { + "usersWanted": [ + { + "authenticationRestrictions": [], + "db": "testDb0", + "user": "testUser0", + "pwd": "somePassword0", + "roles": [ + { + "db": "admin", + "role": "backup" + }, + { + "db": "admin", + "role": "monitor" + }, + { + "db": "admin", + "role": "automation" + } + ], + "mechanisms": [], + "scramSha256Creds": { + "iterationCount": 15000, + "salt": "I570PanWIx1eNUTo7j4ROl2/zIqMsVd6CcIE+A==", + "serverKey": "M4/jskiMM0DpvG/qgMELWlfReqV2ZmwdU8+vJZ/4prc=", + "storedKey": "m1dXf5hHJk7EOAAyJBxfsZvFx1HwtTdda6pFPm0BlOE=" + } + }, + { + "authenticationRestrictions": [], + "db": "testDb1", + "user": "testUser1", + "pwd": "somePassword1", + "unknownFieldOne": "one", + "unknownFieldTwo": "two", + "roles": [ + { + "db": "admin", + "role": "backup" + }, + { + "db": "admin", + "role": "monitor" + }, + { + "db": "admin", + "role": "automation" + } + ], + "mechanisms": [], + "scramSha256Creds": { + "iterationCount": 15000, + "salt": "I570PanWIx1eNUTo7j4ROl2/zIqMsVd6CcIE+A==", + "serverKey": "M4/jskiMM0DpvG/qgMELWlfReqV2ZmwdU8+vJZ/4prc=", + "storedKey": "m1dXf5hHJk7EOAAyJBxfsZvFx1HwtTdda6pFPm0BlOE=" + } + }, + { + "authenticationRestrictions": [], + "db": "testDb2", + "user": "testUser2", + "pwd": "somePassword2", + "roles": [ + { + "db": "admin", + "role": "backup" + }, + { + "db": "admin", + "role": "monitor" + }, + { + "db": "admin", + "role": "automation" + } + ], + "mechanisms": [], + "scramSha256Creds": { + "iterationCount": 15000, + "salt": "I570PanWIx1eNUTo7j4ROl2/zIqMsVd6CcIE+A==", + "serverKey": "M4/jskiMM0DpvG/qgMELWlfReqV2ZmwdU8+vJZ/4prc=", + "storedKey": "m1dXf5hHJk7EOAAyJBxfsZvFx1HwtTdda6pFPm0BlOE=" + } + } + ], + "usersDeleted": [], + "disabled": true, + "authoritativeSet": false, + "autoAuthMechanisms": [], + "autoUser": "mms-automation", + "autoAuthRestrictions": [] + }, + "mongoDbVersions": [ + { + "builds": [ + { + "architecture": "amd64", + "bits": 64, + "gitVersion": "45d947729a0315accb6d4f15a6b06be6d9c19fe7", + "platform": "linux", + "url": "https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-3.2.0.tgz" + }, + { + "architecture": "amd64", + "bits": 64, + "flavor": "ubuntu", + "gitVersion": "45d947729a0315accb6d4f15a6b06be6d9c19fe7", + "maxOsVersion": "15.04", + "minOsVersion": "14.04", + "platform": "linux", + "url": "https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-ubuntu1404-3.2.0.tgz" + }, + { + "architecture": "amd64", + "bits": 64, + "flavor": "amazon", + "gitVersion": "45d947729a0315accb6d4f15a6b06be6d9c19fe7", + "maxOsVersion": "", + "minOsVersion": "2013.03", + "platform": "linux", + "url": "https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-amazon-3.2.0.tgz" + }, + { + "architecture": "amd64", + "bits": 64, + "gitVersion": "45d947729a0315accb6d4f15a6b06be6d9c19fe7", + "platform": "osx", + "url": "https://fastdl.mongodb.org/osx/mongodb-osx-x86_64-3.2.0.tgz" + }, + { + "architecture": "amd64", + "bits": 64, + "gitVersion": "45d947729a0315accb6d4f15a6b06be6d9c19fe7", + "platform": "windows", + "url": "https://fastdl.mongodb.org/win32/mongodb-win32-x86_64-3.2.0.zip" + }, + { + "architecture": "amd64", + "bits": 64, + "gitVersion": "45d947729a0315accb6d4f15a6b06be6d9c19fe7", + "platform": "windows", + "url": "https://fastdl.mongodb.org/win32/mongodb-win32-x86_64-2008plus-3.2.0.zip", + "win2008plus": true + } + ], + "name": "3.2.0" + }, + { + "builds": [ + { + "architecture": "amd64", + "bits": 64, + "flavor": "amazon", + "gitVersion": "45d947729a0315accb6d4f15a6b06be6d9c19fe7", + "maxOsVersion": "", + "minOsVersion": "2013.03", + "modules": [ + "enterprise" + ], + "platform": "linux", + "url": "https://downloads.mongodb.com/linux/mongodb-linux-x86_64-enterprise-amzn64-3.2.0.tgz" + }, + { + "architecture": "amd64", + "bits": 64, + "flavor": "rhel", + "gitVersion": "45d947729a0315accb6d4f15a6b06be6d9c19fe7", + "maxOsVersion": "6.0", + "minOsVersion": "5.7", + "modules": [ + "enterprise" + ], + "platform": "linux", + "url": "https://downloads.mongodb.com/linux/mongodb-linux-x86_64-enterprise-rhel57-3.2.0.tgz" + }, + { + "architecture": "amd64", + "bits": 64, + "flavor": "rhel", + "gitVersion": "45d947729a0315accb6d4f15a6b06be6d9c19fe7", + "maxOsVersion": "7.0", + "minOsVersion": "6.2", + "modules": [ + "enterprise" + ], + "platform": "linux", + "url": "https://downloads.mongodb.com/linux/mongodb-linux-x86_64-enterprise-rhel62-3.2.0.tgz" + }, + { + "architecture": "amd64", + "bits": 64, + "flavor": "rhel", + "gitVersion": "45d947729a0315accb6d4f15a6b06be6d9c19fe7", + "maxOsVersion": "8.0", + "minOsVersion": "7.0", + "modules": [ + "enterprise" + ], + "platform": "linux", + "url": "https://downloads.mongodb.com/linux/mongodb-linux-x86_64-enterprise-rhel70-3.2.0.tgz" + }, + { + "architecture": "amd64", + "bits": 64, + "flavor": "suse", + "gitVersion": "45d947729a0315accb6d4f15a6b06be6d9c19fe7", + "maxOsVersion": "12", + "minOsVersion": "11", + "modules": [ + "enterprise" + ], + "platform": "linux", + "url": "https://downloads.mongodb.com/linux/mongodb-linux-x86_64-enterprise-suse11-3.2.0.tgz" + }, + { + "architecture": "amd64", + "bits": 64, + "flavor": "suse", + "gitVersion": "45d947729a0315accb6d4f15a6b06be6d9c19fe7", + "maxOsVersion": "13", + "minOsVersion": "12", + "modules": [ + "enterprise" + ], + "platform": "linux", + "url": "https://downloads.mongodb.com/linux/mongodb-linux-x86_64-enterprise-suse12-3.2.0.tgz" + }, + { + "architecture": "amd64", + "bits": 64, + "flavor": "ubuntu", + "gitVersion": "45d947729a0315accb6d4f15a6b06be6d9c19fe7", + "maxOsVersion": "13.04", + "minOsVersion": "12.04", + "modules": [ + "enterprise" + ], + "platform": "linux", + "url": "https://downloads.mongodb.com/linux/mongodb-linux-x86_64-enterprise-ubuntu1204-3.2.0.tgz" + }, + { + "architecture": "amd64", + "bits": 64, + "flavor": "ubuntu", + "gitVersion": "45d947729a0315accb6d4f15a6b06be6d9c19fe7", + "maxOsVersion": "15.04", + "minOsVersion": "14.04", + "modules": [ + "enterprise" + ], + "platform": "linux", + "url": "https://downloads.mongodb.com/linux/mongodb-linux-x86_64-enterprise-ubuntu1404-3.2.0.tgz" + }, + { + "architecture": "amd64", + "bits": 64, + "flavor": "debian", + "gitVersion": "45d947729a0315accb6d4f15a6b06be6d9c19fe7", + "maxOsVersion": "8.0", + "minOsVersion": "7.1", + "modules": [ + "enterprise" + ], + "platform": "linux", + "url": "https://downloads.mongodb.com/linux/mongodb-linux-x86_64-enterprise-debian71-3.2.0.tgz" + }, + { + "architecture": "amd64", + "bits": 64, + "gitVersion": "45d947729a0315accb6d4f15a6b06be6d9c19fe7", + "modules": [ + "enterprise" + ], + "platform": "osx", + "url": "https://downloads.mongodb.com/osx/mongodb-osx-x86_64-enterprise-3.2.0.tgz" + }, + { + "architecture": "amd64", + "bits": 64, + "gitVersion": "45d947729a0315accb6d4f15a6b06be6d9c19fe7", + "modules": [ + "enterprise" + ], + "platform": "windows", + "url": "https://downloads.mongodb.com/win32/mongodb-win32-x86_64-enterprise-windows-64-3.2.0.zip", + "win2008plus": true, + "winVCRedistDll": "msvcr120.dll", + "winVCRedistOptions": [ + "/quiet", + "/norestart" + ], + "winVCRedistUrl": "http://download.microsoft.com/download/2/E/6/2E61CFA4-993B-4DD4-91DA-3737CD5CD6E3/vcredist_x64.exe", + "winVCRedistVersion": "12.0.21005.1" + } + ], + "name": "3.2.0-ent" + } + ], + "ldap": {}, + "processes": [], + "replicaSets": [], + "roles": [], + "monitoringVersions": [], + "backupVersions": [], + "mongosqlds": [], + "balancer": {}, + "indexConfigs": [], + "kerberos": { + "serviceName": "mongodb" + }, + "options": { + "downloadBase": "/var/lib/mongodb-mms-automation", + "downloadBaseWindows": "%SystemDrive%\\MMSAutomation\\versions" + }, + "tls": { + "CAFilePath": null, + "CAFilePathWindows": null, + "clientCertificateMode": "OPTIONAL" + }, + "version": null, + "sharding": [] +} diff --git a/controllers/om/testdata/backup_config.json b/controllers/om/testdata/backup_config.json new file mode 100644 index 000000000..427327cf7 --- /dev/null +++ b/controllers/om/testdata/backup_config.json @@ -0,0 +1,29 @@ +{ + "username": "some-username", + "sslPEMKeyFile" : "some-pem-key-file", + "name": "7.6.0.1059-1", + "logPath": "/var/log/mongodb-mms-automation/backup-agent.log", + "logPathWindows": "%SystemDrive%\\MMSAutomation\\log\\mongodb-mms-automation\\backup-agent.log", + "logRotate": { + "sizeThresholdMB": 1000, + "timeThresholdHrs": 24 + }, + "urls": { + "linux": { + "default": "http://ec2-52-73-78-251.compute-1.amazonaws.com:9080/download/agent/backup/mongodb-mms-backup-agent-7.6.0.1059-1.linux_x86_64.tar.gz", + "ppc64le_rhel7": "http://ec2-52-73-78-251.compute-1.amazonaws.com:9080/download/agent/backup/mongodb-mms-backup-agent-7.6.0.1059-1.rhel7_ppc64le.tar.gz", + "ppc64le_ubuntu1604": "http://ec2-52-73-78-251.compute-1.amazonaws.com:9080/download/agent/backup/mongodb-mms-backup-agent-7.6.0.1059-1.ubuntu1604_ppc64le.tar.gz", + "rhel7": "http://ec2-52-73-78-251.compute-1.amazonaws.com:9080/download/agent/backup/mongodb-mms-backup-agent-7.6.0.1059-1.rhel7_x86_64.tar.gz", + "s390x_rhel6": "http://ec2-52-73-78-251.compute-1.amazonaws.com:9080/download/agent/backup/mongodb-mms-backup-agent-7.6.0.1059-1.rhel6_s390x.tar.gz", + "s390x_rhel7": "http://ec2-52-73-78-251.compute-1.amazonaws.com:9080/download/agent/backup/mongodb-mms-backup-agent-7.6.0.1059-1.rhel7_s390x.tar.gz", + "s390x_suse12": "http://ec2-52-73-78-251.compute-1.amazonaws.com:9080/download/agent/backup/mongodb-mms-backup-agent-7.6.0.1059-1.suse12_s390x.tar.gz", + "s390x_ubuntu1804": "http://ec2-52-73-78-251.compute-1.amazonaws.com:9080/download/agent/backup/mongodb-mms-backup-agent-7.6.0.1059-1.ubuntu1804_s390x.tar.gz" + }, + "osx": { + "default": "http://ec2-52-73-78-251.compute-1.amazonaws.com:9080/download/agent/backup/mongodb-mms-backup-agent-7.6.0.1059-1.osx_x86_64.tar.gz" + }, + "windows": { + "default": "http://ec2-52-73-78-251.compute-1.amazonaws.com:9080/download/agent/backup/mongodb-mms-backup-agent-7.6.0.1059-1.windows_x86_64.msi" + } + } +} diff --git a/controllers/om/testdata/deployment_tls.json b/controllers/om/testdata/deployment_tls.json new file mode 100644 index 000000000..87839a6af --- /dev/null +++ b/controllers/om/testdata/deployment_tls.json @@ -0,0 +1,161 @@ +{ + "auth": { + "authoritativeSet": false, + "autoAuthMechanism": "MONGODB-CR", + "autoAuthMechanisms": [], + "autoAuthRestrictions": [], + "disabled": true, + "usersDeleted": [], + "usersWanted": [] + }, + "backupVersions": [ + { + "hostname": "test-tls-additional-domains-0.test-tls-additional-domains-svc.dev.svc.cluster.local", + "name": "6.6.0.959-1" + }, + { + "hostname": "test-tls-additional-domains-1.test-tls-additional-domains-svc.dev.svc.cluster.local", + "name": "6.6.0.959-1" + }, + { + "hostname": "test-tls-additional-domains-2.test-tls-additional-domains-svc.dev.svc.cluster.local", + "name": "6.6.0.959-1" + } + ], + "balancer": {}, + "cpsModules": [], + "indexConfigs": [], + "kerberos": { + "serviceName": "mongodb" + }, + "ldap": {}, + "mongosqlds": [], + "mongots": [], + "monitoringVersions": [ + { + "hostname": "test-tls-additional-domains-0.test-tls-additional-domains-svc.dev.svc.cluster.local", + "name": "6.4.0.433-1" + } + ], + "onlineArchiveModules": [], + "options": { + "downloadBase": "/var/lib/mongodb-mms-automation", + "downloadBaseWindows": "%SystemDrive%\\MMSAutomation\\versions" + }, + "processes": [ + { + "args2_6": { + "net": { + "port": 27017, + "tls": { + "PEMKeyFile": "/mongodb-automation/server.pem", + "mode": "requireSSL" + } + }, + "replication": { + "replSetName": "test-tls-additional-domains" + }, + "storage": { + "dbPath": "/data" + }, + "systemLog": { + "destination": "file", + "path": "/var/log/mongodb-mms-automation/mongodb.log" + } + }, + "authSchemaVersion": 5, + "featureCompatibilityVersion": "3.6", + "hostname": "test-tls-additional-domains-0.test-tls-additional-domains-svc.dev.svc.cluster.local", + "name": "test-tls-additional-domains-0", + "processType": "mongod", + "version": "3.6.8" + }, + { + "args2_6": { + "net": { + "port": 27017, + "tls": { + "PEMKeyFile": "/mongodb-automation/server.pem", + "mode": "requireSSL" + } + }, + "replication": { + "replSetName": "test-tls-additional-domains" + }, + "storage": { + "dbPath": "/data" + }, + "systemLog": { + "destination": "file", + "path": "/var/log/mongodb-mms-automation/mongodb.log" + } + }, + "authSchemaVersion": 5, + "featureCompatibilityVersion": "3.6", + "hostname": "test-tls-additional-domains-1.test-tls-additional-domains-svc.dev.svc.cluster.local", + "name": "test-tls-additional-domains-1", + "processType": "mongod", + "version": "3.6.8" + }, + { + "args2_6": { + "net": { + "port": 27017, + "tls": { + "PEMKeyFile": "/mongodb-automation/server.pem", + "mode": "requireSSL" + } + }, + "replication": { + "replSetName": "test-tls-additional-domains" + }, + "storage": { + "dbPath": "/data" + }, + "systemLog": { + "destination": "file", + "path": "/var/log/mongodb-mms-automation/mongodb.log" + } + }, + "authSchemaVersion": 5, + "featureCompatibilityVersion": "3.6", + "hostname": "test-tls-additional-domains-2.test-tls-additional-domains-svc.dev.svc.cluster.local", + "name": "test-tls-additional-domains-2", + "processType": "mongod", + "version": "3.6.8" + } + ], + "replicaSets": [ + { + "_id": "test-tls-additional-domains", + "members": [ + { + "_id": 0, + "host": "test-tls-additional-domains-0", + "priority": 1, + "votes": 1 + }, + { + "_id": 1, + "host": "test-tls-additional-domains-1", + "priority": 1, + "votes": 1 + }, + { + "_id": 2, + "host": "test-tls-additional-domains-2", + "priority": 1, + "votes": 1 + } + ], + "protocolVersion": "1" + } + ], + "roles": [], + "sharding": [], + "tls": { + "CAFilePath": "/mongodb-automation/ca.pem", + "clientCertificateMode": "OPTIONAL" + }, + "version": 138 +} diff --git a/controllers/om/testdata/monitoring_config.json b/controllers/om/testdata/monitoring_config.json new file mode 100644 index 000000000..cf6ff94cd --- /dev/null +++ b/controllers/om/testdata/monitoring_config.json @@ -0,0 +1,27 @@ +{ + "name": "6.6.2.466-1", + "logPath": "/var/log/mongodb-mms-automation/monitoring-agent.log", + "logPathWindows": "%SystemDrive%\\MMSAutomation\\log\\mongodb-mms-automation\\monitoring-agent.log", + "logRotate": { + "sizeThresholdMB": 1000, + "timeThresholdHrs": 24 + }, + "urls": { + "linux": { + "default": "http://ec2-54-196-186-93.compute-1.amazonaws.com:9080/download/agent/monitoring/mongodb-mms-monitoring-agent-6.6.2.466-1.linux_x86_64.tar.gz", + "ppc64le_rhel7": "http://ec2-54-196-186-93.compute-1.amazonaws.com:9080/download/agent/monitoring/mongodb-mms-monitoring-agent-6.6.2.466-1.rhel7_ppc64le.tar.gz", + "ppc64le_ubuntu1604": "http://ec2-54-196-186-93.compute-1.amazonaws.com:9080/download/agent/monitoring/mongodb-mms-monitoring-agent-6.6.2.466-1.ubuntu1604_ppc64le.tar.gz", + "rhel7": "http://ec2-54-196-186-93.compute-1.amazonaws.com:9080/download/agent/monitoring/mongodb-mms-monitoring-agent-6.6.2.466-1.rhel7_x86_64.tar.gz", + "s390x_rhel6": "http://ec2-54-196-186-93.compute-1.amazonaws.com:9080/download/agent/monitoring/mongodb-mms-monitoring-agent-6.6.2.466-1.rhel6_s390x.tar.gz", + "s390x_rhel7": "http://ec2-54-196-186-93.compute-1.amazonaws.com:9080/download/agent/monitoring/mongodb-mms-monitoring-agent-6.6.2.466-1.rhel7_s390x.tar.gz", + "s390x_suse12": "http://ec2-54-196-186-93.compute-1.amazonaws.com:9080/download/agent/monitoring/mongodb-mms-monitoring-agent-6.6.2.466-1.suse12_s390x.tar.gz", + "s390x_ubuntu1804": "http://ec2-54-196-186-93.compute-1.amazonaws.com:9080/download/agent/monitoring/mongodb-mms-monitoring-agent-6.6.2.466-1.ubuntu1804_s390x.tar.gz" + }, + "osx": { + "default": "http://ec2-54-196-186-93.compute-1.amazonaws.com:9080/download/agent/monitoring/mongodb-mms-monitoring-agent-6.6.2.466-1.osx_x86_64.tar.gz" + }, + "windows": { + "default": "http://ec2-54-196-186-93.compute-1.amazonaws.com:9080/download/agent/monitoring/mongodb-mms-monitoring-agent-6.6.2.466-1.windows_x86_64.msi" + } + } +} \ No newline at end of file diff --git a/controllers/operator/agents/agents.go b/controllers/operator/agents/agents.go new file mode 100644 index 000000000..27994b31a --- /dev/null +++ b/controllers/operator/agents/agents.go @@ -0,0 +1,171 @@ +package agents + +import ( + "errors" + "fmt" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "golang.org/x/xerrors" + + v1 "github.com/10gen/ops-manager-kubernetes/api/v1" + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/secrets" + "github.com/10gen/ops-manager-kubernetes/pkg/dns" + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" + "github.com/10gen/ops-manager-kubernetes/pkg/vault" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/secret" + "go.uber.org/zap" + appsv1 "k8s.io/api/apps/v1" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type SecretGetCreator interface { + secret.Getter + secret.Creator +} + +type retryParams struct { + waitSeconds int + retrials int +} + +// ensureAgentKeySecretExists checks if the Secret with specified name (-group-secret) exists, otherwise tries to +// generate agent key using OM public API and create Secret containing this key. Generation of a key is expected to be +// a rare operation as the group creation api generates agent key already (so the only possible situation is when the group +// was created externally and agent key wasn't generated before) +// Returns the api key existing/generated +func EnsureAgentKeySecretExists(secretGetCreator secrets.SecretClient, agentKeyGenerator om.AgentKeyGenerator, namespace, agentKey, projectId string, basePath string, log *zap.SugaredLogger) error { + secretName := ApiKeySecretName(projectId) + log = log.With("secret", secretName) + _, err := secretGetCreator.GetSecret(kube.ObjectKey(namespace, secretName)) + if err != nil { + if agentKey == "" { + log.Info("Generating agent key as current project doesn't have it") + + agentKey, err = agentKeyGenerator.GenerateAgentKey() + if err != nil { + return xerrors.Errorf("failed to generate agent key in OM: %w", err) + } + log.Info("Agent key was successfully generated") + } + + data := map[string]interface{}{ + "data": map[string]interface{}{ + util.OmAgentApiKey: agentKey, + }, + } + + if vault.IsVaultSecretBackend() { + // we only want to create secret if it doesn't exist in vault + APIKeyPath := fmt.Sprintf("%s/%s/%s", basePath, namespace, secretName) + _, err := secretGetCreator.VaultClient.ReadSecretBytes(APIKeyPath) + if err != nil && secrets.SecretNotExist(err) { + err = secretGetCreator.VaultClient.PutSecret(APIKeyPath, data) + if err != nil { + return xerrors.Errorf("failed to create AgentKey secret in vault: %w", err) + } + log.Infof("Project agent key is saved in Vault") + return nil + } + return err + } + + // todo pass a real owner in a next PR + if err = createAgentKeySecret(secretGetCreator, kube.ObjectKey(namespace, secretName), agentKey, nil); err != nil { + if apiErrors.IsAlreadyExists(err) { + return nil + } + return xerrors.Errorf("failed to create Secret: %w", err) + } + log.Infof("Project agent key is saved in Kubernetes Secret for later usage") + return nil + } + + return nil +} + +// ApiKeySecretName for a given ProjectID (`project`) returns the name of +// the secret associated with it. +func ApiKeySecretName(project string) string { + return fmt.Sprintf("%s-group-secret", project) +} + +// WaitForRsAgentsToRegister waits until all the agents associated with the given StatefulSet have registered with Ops Manager. +func WaitForRsAgentsToRegister(set appsv1.StatefulSet, members int, clusterName string, omConnection om.Connection, log *zap.SugaredLogger, rs *mdbv1.MongoDB) error { + hostnames, _ := dns.GetDnsForStatefulSetReplicasSpecified(set, clusterName, members, rs.Spec.DbCommonSpec.GetExternalDomain()) + + log = log.With("statefulset", set.Name) + + if !waitUntilRegistered(omConnection, log, retryParams{retrials: 5, waitSeconds: 3}, hostnames...) { + return errors.New("some agents failed to register or the Operator is using the wrong host names for the pods. " + + "Make sure the 'spec.clusterDomain' is set if it's different from the default Kubernetes cluster " + + "name ('cluster.local') ") + } + return nil +} + +// WaitForRsAgentsToRegisterReplicasSpecifiedMultiCluster waits for the specified agents to registry with Ops Manager. +func WaitForRsAgentsToRegisterReplicasSpecifiedMultiCluster(omConnection om.Connection, hostnames []string, log *zap.SugaredLogger) error { + if !waitUntilRegistered(omConnection, log, retryParams{retrials: 10, waitSeconds: 9}, hostnames...) { + return errors.New("some agents failed to register or the Operator is using the wrong host names for the pods. " + + "Make sure the 'spec.clusterDomain' is set if it's different from the default Kubernetes cluster " + + "name ('cluster.local') ") + } + return nil +} + +// waitUntilRegistered waits until all agents with 'agentHostnames' are registered in OM. Note, that wait +// happens after retrial - this allows to skip waiting in case agents are already registered +func waitUntilRegistered(omConnection om.Connection, log *zap.SugaredLogger, r retryParams, agentHostnames ...string) bool { + log.Infow("Waiting for agents to register with OM", "agent hosts", agentHostnames) + // environment variables are used only for tests + waitSeconds := env.ReadIntOrDefault(util.PodWaitSecondsEnv, r.waitSeconds) + retrials := env.ReadIntOrDefault(util.PodWaitRetriesEnv, r.retrials) + + agentsCheckFunc := func() (string, bool) { + registeredCount := 0 + found, err := om.TraversePages( + omConnection.ReadAutomationAgents, + func(aa interface{}) bool { + automationAgent := aa.(om.AgentStatus) + + for _, hostname := range agentHostnames { + if automationAgent.IsRegistered(hostname, log) { + registeredCount++ + if registeredCount == len(agentHostnames) { + return true + } + } + } + return false + }, + ) + + if err != nil { + log.Errorw("Received error when reading automation agent pages", "err", err) + } + + var msg string + if registeredCount == 0 { + msg = fmt.Sprintf("None of %d agents has registered with OM", len(agentHostnames)) + } else { + msg = fmt.Sprintf("Only %d of %d agents have registered with OM", registeredCount, len(agentHostnames)) + } + return msg, found + } + + return util.DoAndRetry(agentsCheckFunc, log, retrials, waitSeconds) +} + +func createAgentKeySecret(secretCreator secrets.SecretClient, objectKey client.ObjectKey, agentKey string, owner v1.CustomResourceReadWriter) error { + agentKeySecret := secret.Builder(). + SetField(util.OmAgentApiKey, agentKey). + SetOwnerReferences(kube.BaseOwnerReference(owner)). + SetName(objectKey.Name). + SetNamespace(objectKey.Namespace). + Build() + return secretCreator.KubeClient.CreateSecret(agentKeySecret) +} diff --git a/controllers/operator/agents/upgrade.go b/controllers/operator/agents/upgrade.go new file mode 100644 index 000000000..9cece3fd7 --- /dev/null +++ b/controllers/operator/agents/upgrade.go @@ -0,0 +1,152 @@ +package agents + +import ( + "context" + "sync" + "time" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/project" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/secrets" + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + kubernetesClient "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/client" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/configmap" + "go.uber.org/zap" + "golang.org/x/xerrors" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var nextScheduledTime time.Time + +const pause = time.Hour * 24 + +var mux sync.Mutex + +func init() { + ScheduleUpgrade() +} + +// UpgradeAllIfNeeded performs the upgrade of agents for all the MongoDB resources registered in the system if necessary +// It's designed to be run "in background" - so must not break any existing reconciliations it's triggered from and +// so doesn't return errors +// Concurrency behavior: the mutex is used to: +// 1. ensure no separate routines invoke the upgrade in parallel +// 2. different reconciliations started in parallel (e.g. Operator has restarted) wait for the upgrade procedure to happen +// for all existing MongoDB resources before proceeding. This could be a critical thing when the major version OM upgrade +// happens and all existing MongoDBs are required to get agents upgraded (otherwise the "You need to upgrade the +// automation agent before publishing other changes" error happens for automation config pushes from the Operator) +func UpgradeAllIfNeeded(client kubernetesClient.Client, secretGetter secrets.SecretClient, omConnectionFactory om.ConnectionFactory, watchNamespace []string) { + mux.Lock() + defer mux.Unlock() + + if !time.Now().After(nextScheduledTime) { + return + } + log := zap.S() + log.Info("Performing a regular upgrade of Agents for all the MongoDB resources in the cluster...") + + allMDBs, err := readAllMongoDBs(client, watchNamespace) + if err != nil { + log.Errorf("Failed to read MongoDB resources to ensure Agents have the latest version: %s", err) + return + } + + err = doUpgrade(client, secretGetter, omConnectionFactory, allMDBs) + if err != nil { + log.Errorf("Failed to perform upgrade of Agents: %s", err) + } + + log.Info("The upgrade of Agents for all the MongoDB resources in the cluster is finished.") + + nextScheduledTime = nextScheduledTime.Add(pause) +} + +// ScheduleUpgrade allows to reset the timer to Now() which makes sure the next MongoDB reconciliation will ensure +// all the watched agents are up-to-date. +// This is needed for major/minor OM upgrades as all dependent MongoDBs won't get reconciled with "You need to upgrade the +// automation agent before publishing other changes" +func ScheduleUpgrade() { + nextScheduledTime = time.Now() +} + +// NextScheduledUpgradeTime returns the next scheduled time. Mostly needed for testing. +func NextScheduledUpgradeTime() time.Time { + return nextScheduledTime +} + +func doUpgrade(cmGetter configmap.Getter, secretGetter secrets.SecretClient, factory om.ConnectionFactory, mdbs []mdbv1.MongoDB) error { + for _, mdb := range mdbs { + log := zap.S().With(string(mdb.Spec.ResourceType), mdb.ObjectKey()) + conn, err := connectToMongoDB(cmGetter, secretGetter, factory, mdb, log) + if err != nil { + log.Warnf("Failed to establish connection to Ops Manager to perform Agent upgrade: %s", err) + continue + } + + currentVersion := "" + if deployment, err := conn.ReadDeployment(); err == nil { + currentVersion = deployment.GetAgentVersion() + } + version, err := conn.UpgradeAgentsToLatest() + if err != nil { + log.Warnf("Failed to schedule Agent upgrade: %s, this could be due do ongoing Automation Config publishing in Ops Manager and will get fixed during next trial", err) + continue + } + if currentVersion != version && currentVersion != "" { + log.Debugf("Submitted the request to Ops Manager to upgrade the agents from %s to the latest version (%s)", currentVersion, version) + } + } + return nil +} + +// readAllMongoDBs returns a list of all the MongoDB resources found in the +// `watchNamespace` list. +// +// If the `watchNamespace` contains only the "" string, the MongoDB resources +// will be searched in every Namespace of the cluster. +func readAllMongoDBs(cl client.Client, watchNamespace []string) ([]mdbv1.MongoDB, error) { + var namespaces []string + + // 1. Find which Namespaces to look for MongoDB resources + if len(watchNamespace) == 1 && watchNamespace[0] == "" { + namespaceList := corev1.NamespaceList{} + if err := cl.List(context.TODO(), &namespaceList); err != nil { + return []mdbv1.MongoDB{}, err + } + for _, item := range namespaceList.Items { + namespaces = append(namespaces, item.Name) + } + } else { + namespaces = watchNamespace + } + + mdbs := []mdbv1.MongoDB{} + // 2. Find all MongoDBs in the namespaces + for _, ns := range namespaces { + mongodbList := mdbv1.MongoDBList{} + if err := cl.List(context.TODO(), &mongodbList, client.InNamespace(ns)); err != nil { + return []mdbv1.MongoDB{}, err + } + mdbs = append(mdbs, mongodbList.Items...) + } + return mdbs, nil +} + +func connectToMongoDB(cmGetter configmap.Getter, secretGetter secrets.SecretClient, factory om.ConnectionFactory, mdb mdbv1.MongoDB, log *zap.SugaredLogger) (om.Connection, error) { + projectConfig, err := project.ReadProjectConfig(cmGetter, kube.ObjectKey(mdb.Namespace, mdb.Spec.GetProject()), mdb.Name) + if err != nil { + return nil, xerrors.Errorf("error reading Project Config: %w", err) + } + credsConfig, err := project.ReadCredentials(secretGetter, kube.ObjectKey(mdb.Namespace, mdb.Spec.Credentials), log) + if err != nil { + return nil, xerrors.Errorf("error reading Credentials secret: %w", err) + } + + _, conn, err := project.ReadOrCreateProject(projectConfig, credsConfig, factory, log) + if err != nil { + return nil, xerrors.Errorf("error reading or creating project in Ops Manager: %w", err) + } + return conn, nil +} diff --git a/controllers/operator/appdbreplicaset_controller.go b/controllers/operator/appdbreplicaset_controller.go new file mode 100644 index 000000000..c471ed2f5 --- /dev/null +++ b/controllers/operator/appdbreplicaset_controller.go @@ -0,0 +1,1004 @@ +package operator + +import ( + "fmt" + "path" + "strings" + + mdbcv1 "github.com/mongodb/mongodb-kubernetes-operator/api/v1" + mdbcv1_controllers "github.com/mongodb/mongodb-kubernetes-operator/controllers" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/util/merge" + "golang.org/x/xerrors" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/util/result" + + "github.com/10gen/ops-manager-kubernetes/pkg/dns" + "github.com/10gen/ops-manager-kubernetes/pkg/tls" + "github.com/10gen/ops-manager-kubernetes/pkg/vault" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/agent" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/authentication/scram" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/automationconfig" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/util/generate" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/util/scale" + "github.com/stretchr/objx" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + omv1 "github.com/10gen/ops-manager-kubernetes/api/v1/om" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/certs" + enterprisepem "github.com/10gen/ops-manager-kubernetes/controllers/operator/pem" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/secrets" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/watch" + + "github.com/10gen/ops-manager-kubernetes/api/v1/status" + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/controllers/om/apierror" + "github.com/10gen/ops-manager-kubernetes/controllers/om/host" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/agents" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/authentication" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/construct" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/create" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/project" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/workflow" + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/annotations" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/configmap" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/secret" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/statefulset" + "go.uber.org/zap" +) + +type agentType string + +const ( + appdbCAFilePath = "/var/lib/mongodb-automation/secrets/ca/ca-pem" + + monitoring agentType = "MONITORING" + automation agentType = "AUTOMATION" + + // Used to note that for this particular case it is not necessary to pass + // the hash of the Prometheus certificate. This is to avoid having to + // calculate and pass the Prometheus Cert Hash when it is not needed. + UnusedPrometheusConfiguration string = "" +) + +// ReconcileAppDbReplicaSet reconciles a MongoDB with a type of ReplicaSet +type ReconcileAppDbReplicaSet struct { + *ReconcileCommonController + omConnectionFactory om.ConnectionFactory + versionMappingProvider func(string) ([]byte, error) +} + +func newAppDBReplicaSetReconciler(commonController *ReconcileCommonController, omConnectionFactory om.ConnectionFactory, versionMappingProvider func(string) ([]byte, error)) *ReconcileAppDbReplicaSet { + return &ReconcileAppDbReplicaSet{ + ReconcileCommonController: commonController, + omConnectionFactory: omConnectionFactory, + versionMappingProvider: versionMappingProvider, + } +} + +// shouldReconcileAppDB returns a boolean indicating whether or not the reconciliation for this set of processes should occur. +func (r *ReconcileAppDbReplicaSet) shouldReconcileAppDB(opsManager omv1.MongoDBOpsManager, log *zap.SugaredLogger) (bool, error) { + currentAc, err := automationconfig.ReadFromSecret(r.client, types.NamespacedName{ + Namespace: opsManager.GetNamespace(), + Name: opsManager.Spec.AppDB.AutomationConfigSecretName(), + }) + + if err != nil { + return false, xerrors.Errorf("error reading AppDB Automation Config: %w", err) + } + + // there is no automation config yet, we can safely reconcile. + if currentAc.Processes == nil { + return true, nil + } + + desiredAc, err := r.buildAppDbAutomationConfig(opsManager, appsv1.StatefulSet{}, automation, UnusedPrometheusConfiguration, log) + if err != nil { + return false, xerrors.Errorf("error building AppDB Automation Config: %w", err) + } + + currentProcessesAreDisabled := false + for _, p := range currentAc.Processes { + if p.Disabled { + currentProcessesAreDisabled = true + break + } + } + + desiredProcessesAreDisabled := false + for _, p := range desiredAc.Processes { + if p.Disabled { + desiredProcessesAreDisabled = true + break + } + } + + // skip the reconciliation as there are disabled processes, and we are not attempting to re-enable them. + if currentProcessesAreDisabled && desiredProcessesAreDisabled { + return false, nil + } + + return true, nil +} + +// ReconcileAppDB deploys the "headless" agent, and wait until it reaches the goal state +func (r *ReconcileAppDbReplicaSet) ReconcileAppDB(opsManager *omv1.MongoDBOpsManager, opsManagerUserPassword string) (res reconcile.Result, e error) { + rs := opsManager.Spec.AppDB + log := zap.S().With("ReplicaSet (AppDB)", kube.ObjectKey(opsManager.Namespace, rs.Name())) + + appDbStatusOption := status.NewOMPartOption(status.AppDb) + omStatusOption := status.NewOMPartOption(status.OpsManager) + + log.Info("AppDB ReplicaSet.Reconcile") + log.Infow("ReplicaSet.Spec", "spec", rs) + log.Infow("ReplicaSet.Status", "status", opsManager.Status.AppDbStatus) + + // if any of the processes have been marked as disabled, we don't reconcile the AppDB. + // This could be the case if we want to disable a process to perform a manual backup of the AppDB. + shouldReconcile, err := r.shouldReconcileAppDB(*opsManager, log) + if err != nil { + return r.updateStatus(opsManager, workflow.Failed(xerrors.Errorf("Error determining AppDB reconciliation state: %w", err)), log, appDbStatusOption) + } + if !shouldReconcile { + log.Info("Skipping reconciliation for AppDB because at least one of the processes has been disabled. To reconcile the AppDB all process need to be enabled in automation config") + return result.OK() + } + + reconcileResult, err := r.updateStatus(opsManager, workflow.Reconciling(), log, appDbStatusOption) + if err != nil { + return reconcileResult, err + } + + podVars, err := r.tryConfigureMonitoringInOpsManager(opsManager, opsManagerUserPassword, log) + // it's possible that Ops Manager will not be available when we attempt to configure AppDB monitoring + // in Ops Manager. This is not a blocker to continue with the reset of the reconciliation. + if err != nil { + log.Errorf("Unable to configure monitoring of AppDB: %s, configuration will be attempted next reconciliation.", err) + // errors returned from "tryConfigureMonitoringInOpsManager" could be either transient or persistent. Transient errors could be when the ops-manager pods + // are not ready and trying to connect to the ops-manager service timesout, a persistent error is when the "ops-manager-admin-key" is corrputed, in this case + // any API call to ops-manager will fail(including the confguration of AppDB monitoring), this error should be reflected to the user in the "OPSMANAGER" status. + if strings.Contains(err.Error(), "401 (Unauthorized)") { + return r.updateStatus(opsManager, workflow.Failed(xerrors.Errorf("The admin-key secret might be corrupted: %w", err)), log, omStatusOption) + } + } + + monitoringAgentVersion, err := getMonitoringAgentVersion(*opsManager, r.versionMappingProvider) + if err != nil { + return r.updateStatus(opsManager, workflow.Failed(xerrors.Errorf("Error reading monitoring agent version: %w", err)), log, appDbStatusOption) + } + + workflowStatus := r.ensureTLSSecretAndCreatePEMIfNeeded(*opsManager, log) + if !workflowStatus.IsOK() { + return r.updateStatus(opsManager, workflowStatus, log, appDbStatusOption) + } + var appdbSecretPath string + if r.VaultClient != nil { + appdbSecretPath = r.VaultClient.AppDBSecretPath() + } + tlsSecretName := opsManager.Spec.AppDB.GetSecurity().MemberCertificateSecretName(opsManager.Spec.AppDB.Name()) + certHash := enterprisepem.ReadHashFromSecret(r.SecretClient, opsManager.Namespace, tlsSecretName, appdbSecretPath, log) + + appdbOpts := construct.AppDBStatefulSetOptions{} + appdbOpts.CertHash = certHash + appdbOpts.MonitoringAgentVersion = monitoringAgentVersion + + var vaultConfig vault.VaultConfiguration + if r.VaultClient != nil { + vaultConfig = r.VaultClient.VaultConfig + } + appdbOpts.VaultConfig = vaultConfig + + prometheusCertHash, err := certs.EnsureTLSCertsForPrometheus(r.SecretClient, opsManager.GetNamespace(), opsManager.Spec.AppDB.Prometheus, certs.AppDB, log) + if err != nil { + // Do not fail on errors generating certs for Prometheus + log.Errorf("can't create a PEM-Format Secret for Prometheus certificates: %s", err) + } + appdbOpts.PrometheusTLSCertHash = prometheusCertHash + + appDbSts, err := construct.AppDbStatefulSet(*opsManager, &podVars, appdbOpts, log) + if err != nil { + return r.updateStatus(opsManager, workflow.Failed(xerrors.Errorf("can't construct AppDB Statefulset: %w", err)), log, omStatusOption) + } + + if workflowStatus := r.reconcileAppDB(*opsManager, appDbSts, appdbOpts, log); !workflowStatus.IsOK() { + return r.updateStatus(opsManager, workflowStatus, log, appDbStatusOption) + } + + if err := annotations.UpdateLastAppliedMongoDBVersion(opsManager, r.client); err != nil { + return r.updateStatus(opsManager, workflow.Failed(xerrors.Errorf("Could not save current state as an annotation: %w", err)), log, omStatusOption) + } + if err := statefulset.ResetUpdateStrategy(opsManager, r.client); err != nil { + return r.updateStatus(opsManager, workflow.Failed(xerrors.Errorf("can't reset AppDB StatefulSet UpdateStrategyType: %w", err)), log, omStatusOption) + } + + if podVars.ProjectID == "" { + // this doesn't requeue the reconciliation immediately, the calling OM controller + // requeues after Ops Manager has been fully configured. + log.Infof("Requeuing reconciliation to configure Monitoring in Ops Manager.") + return r.updateStatus(opsManager, workflow.OK().Requeue(), log, appDbStatusOption, status.MembersOption(opsManager)) + } + + if scale.IsStillScaling(opsManager) { + return r.updateStatus(opsManager, workflow.Pending("Continuing scaling operation on AppDB desiredMembers=%d, currentMembers=%d", + opsManager.DesiredReplicas(), scale.ReplicasThisReconciliation(opsManager)), log, appDbStatusOption, status.MembersOption(opsManager)) + } + + log.Infof("Finished reconciliation for AppDB ReplicaSet!") + + return r.updateStatus(opsManager, workflow.OK(), log, appDbStatusOption, status.MembersOption(opsManager)) +} + +// reconcileAppDB performs the reconciliation for the AppDB: update the AutomationConfig Secret if necessary and +// update the StatefulSet. It does it in the necessary order depending on the changes to the spec +func (r *ReconcileAppDbReplicaSet) reconcileAppDB(opsManager omv1.MongoDBOpsManager, appDbSts appsv1.StatefulSet, appDbOpts construct.AppDBStatefulSetOptions, log *zap.SugaredLogger) workflow.Status { + automationConfigFirst := true + + currentAc, err := automationconfig.ReadFromSecret(r.SecretClient, types.NamespacedName{ + Namespace: opsManager.GetNamespace(), + Name: opsManager.Spec.AppDB.AutomationConfigSecretName(), + }) + if err != nil { + return workflow.Failed(xerrors.Errorf("can't read existing automation config from secret")) + } + + // The only case when we push the StatefulSet first is when we are ensuring TLS for the already existing AppDB + _, err = r.client.GetStatefulSet(kube.ObjectKey(opsManager.Namespace, opsManager.Spec.AppDB.Name())) + if err == nil && opsManager.Spec.AppDB.GetSecurity().IsTLSEnabled() { + automationConfigFirst = false + } + + // Set it to true if the currentAC has the old keyfile path + // This is needed for appdb upgrade from 1 to 3 contaienrs + // as the AC contains the new path of the keyfile and the agents needs it + if currentAc.Auth.KeyFile == util.AutomationAgentKeyFilePathInContainer { + automationConfigFirst = true + } + if opsManager.IsChangingVersion() { + log.Info("Version change in progress, the StatefulSet must be updated first") + automationConfigFirst = false + } + + if !automationConfigFirst { + // In an upgrade scenario from pre-config map, we would be pushing the StatefulSet first. + // In this case, the ConfigMap would not be present and the statefulset will fail in creating the pods. + // So we make sure that the configmap is there. + r.publishACVersionAsConfigMap(opsManager.Spec.AppDB.AutomationConfigConfigMapName(), opsManager.Namespace, currentAc.Version) + + currentMonitoringAc, err := automationconfig.ReadFromSecret(r.SecretClient, kube.ObjectKey(opsManager.Namespace, opsManager.Spec.AppDB.MonitoringAutomationConfigSecretName())) + if err != nil { + if !secrets.SecretNotExist(err) { + return workflow.Failed(xerrors.Errorf("can't read existing monitoring automation config: %w", err)) + } + } else { + r.publishACVersionAsConfigMap(opsManager.Spec.AppDB.MonitoringAutomationConfigConfigMapName(), opsManager.Namespace, currentMonitoringAc.Version) + } + } + + return workflow.RunInGivenOrder(automationConfigFirst, + func() workflow.Status { + log.Info("Deploying Automation Config") + return r.deployAutomationConfig(opsManager, appDbSts, appDbOpts.PrometheusTLSCertHash, log) + }, + func() workflow.Status { + + // in the case of an upgrade from the 1 to 3 container architecture, when the stateful set is updated before the agent automation config + // the monitoring agent automation config needs to exist for the volumes to mount correctly. + if err := r.deployMonitoringAgentAutomationConfig(opsManager, appDbSts, log); err != nil { + return workflow.Failed(err) + } + + log.Info("Deploying Statefulset") + return r.deployStatefulSet(opsManager, appDbSts, log) + }) +} + +func getDomain(service, namespace, clusterName string) string { + if clusterName == "" { + clusterName = "cluster.local" + } + return fmt.Sprintf("%s.%s.svc.%s", service, namespace, clusterName) +} + +// ensureTLSSecretAndCreatePEMIfNeeded checks that the needed TLS secrets are present, and creates the concatenated PEM if needed. +// This means that the secret referenced can either already contain a concatenation of certificate and private key +// or it can be of type kubernetes.io/tls. In this case the operator will read the tls.crt and tls.key entries, and it will +// generate a new secret containing their concatenation +func (r *ReconcileAppDbReplicaSet) ensureTLSSecretAndCreatePEMIfNeeded(om omv1.MongoDBOpsManager, log *zap.SugaredLogger) workflow.Status { + rs := om.Spec.AppDB + if !rs.IsSecurityTLSConfigEnabled() { + return workflow.OK() + } + secretName := rs.Security.MemberCertificateSecretName(rs.Name()) + + needToCreatePEM := false + var err error + var secretData map[string][]byte + var s corev1.Secret + + if vault.IsVaultSecretBackend() { + needToCreatePEM = true + path := fmt.Sprintf("%s/%s/%s", r.VaultClient.AppDBSecretPath(), om.Namespace, secretName) + secretData, err = r.VaultClient.ReadSecretBytes(path) + if err != nil { + return workflow.Failed(xerrors.Errorf("can't read current certificate secret from vault: %w", err)) + } + } else { + s, err = r.KubeClient.GetSecret(kube.ObjectKey(om.Namespace, secretName)) + if err != nil { + return workflow.Failed(xerrors.Errorf("can't read current certificate secret: %w", err)) + } + + // SecretTypeTLS is kubernetes.io/tls + // This is the standard way in K8S to have secrets that hold TLS certs + // And it is the one generated by cert manager + // These type of secrets contain tls.crt and tls.key entries + if s.Type == corev1.SecretTypeTLS { + needToCreatePEM = true + secretData = s.Data + } + } + + if needToCreatePEM { + data, err := certs.VerifyTLSSecretForStatefulSet(secretData, certs.AppDBReplicaSetConfig(om)) + if err != nil { + return workflow.Failed(xerrors.Errorf("certificate for appdb is not valid: %w", err)) + } + + var appdbSecretPath string + if r.VaultClient != nil { + appdbSecretPath = r.VaultClient.AppDBSecretPath() + } + + secretHash := enterprisepem.ReadHashFromSecret(r.SecretClient, om.Namespace, secretName, appdbSecretPath, log) + err = certs.CreatePEMSecretClient(r.SecretClient, kube.ObjectKey(om.Namespace, secretName), map[string]string{secretHash: data}, om.GetOwnerReferences(), certs.AppDB) + if err != nil { + return workflow.Failed(xerrors.Errorf("can't create concatenated PEM certificate: %w", err)) + } + } + + return workflow.OK() +} + +// publishAutomationConfig publishes the automation config to the Secret if necessary. Note that it's done only +// if the automation config has changed - the version is incremented in this case. +// Method returns the version of the automation config. +// No optimistic concurrency control is done - there cannot be a concurrent reconciliation for the same Ops Manager +// object and the probability that the user will edit the config map manually in the same time is extremely low +// returns the version of AutomationConfig just published +func (r *ReconcileAppDbReplicaSet) publishAutomationConfig(opsManager omv1.MongoDBOpsManager, automationConfig automationconfig.AutomationConfig, secretName string) (int, error) { + ac, err := automationconfig.EnsureSecret(r.SecretClient, kube.ObjectKey(opsManager.Namespace, secretName), kube.BaseOwnerReference(&opsManager), automationConfig) + if err != nil { + return -1, err + } + return ac.Version, err +} + +func (r ReconcileAppDbReplicaSet) buildAppDbAutomationConfig(opsManager omv1.MongoDBOpsManager, set appsv1.StatefulSet, acType agentType, prometheusCertHash string, log *zap.SugaredLogger) (automationconfig.AutomationConfig, error) { + rs := opsManager.Spec.AppDB + domain := getDomain(rs.ServiceName(), opsManager.Namespace, opsManager.Spec.GetClusterDomain()) + auth := automationconfig.Auth{} + appDBConfigurable := omv1.AppDBConfigurable{AppDBSpec: rs, OpsManager: opsManager} + if err := scram.Enable(&auth, r.SecretClient, &appDBConfigurable); err != nil { + return automationconfig.AutomationConfig{}, err + } + // the existing automation config is required as we compare it against what we build to determine + // if we need to increment the version. + secretName := rs.AutomationConfigSecretName() + if acType == monitoring { + secretName = rs.MonitoringAutomationConfigSecretName() + } + existingAutomationConfig, err := automationconfig.ReadFromSecret(r.client, types.NamespacedName{Name: secretName, Namespace: opsManager.Namespace}) + if err != nil { + return automationconfig.AutomationConfig{}, err + } + fcVersion := "" + if rs.FeatureCompatibilityVersion != nil { + fcVersion = *rs.FeatureCompatibilityVersion + } + tlsSecretName := opsManager.Spec.AppDB.GetSecurity().MemberCertificateSecretName(opsManager.Spec.AppDB.Name()) + var appdbSecretPath string + if r.VaultClient != nil { + appdbSecretPath = r.VaultClient.AppDBSecretPath() + } + certHash := enterprisepem.ReadHashFromSecret(r.SecretClient, opsManager.Namespace, tlsSecretName, appdbSecretPath, log) + + prometheusModification := automationconfig.NOOP() + if acType == automation { + // There are 2 agents running in the AppDB Pods, we will configure Prometheus + // only on the Automation Agent. + prometheusModification, err = buildPrometheusModification(r.SecretClient, opsManager, prometheusCertHash) + if err != nil { + log.Errorf("Could not enable Prometheus: %s", err) + } + } + // get member options from appDB spec + memberOptions := opsManager.Spec.AppDB.GetMemberOptions() + + ac, err := automationconfig.NewBuilder(). + SetTopology(automationconfig.ReplicaSetTopology). + SetMemberOptions(memberOptions). + SetMembers(scale.ReplicasThisReconciliation(&opsManager)). + SetName(rs.Name()). + SetDomain(domain). + SetAuth(auth). + SetFCV(fcVersion). + AddVersions(existingAutomationConfig.Versions). + IsEnterprise(construct.IsEnterprise()). + SetMongoDBVersion(rs.GetMongoDBVersion()). + SetOptions(automationconfig.Options{DownloadBase: util.AgentDownloadsDir}). + SetPreviousAutomationConfig(existingAutomationConfig). + SetTLSConfig( + automationconfig.TLS{ + CAFilePath: appdbCAFilePath, + ClientCertificateMode: automationconfig.ClientCertificateModeOptional, + }). + AddModifications(func(automationConfig *automationconfig.AutomationConfig) { + if acType == monitoring { + addMonitoring(automationConfig, log, rs.GetSecurity().IsTLSEnabled()) + automationConfig.ReplicaSets = []automationconfig.ReplicaSet{} + automationConfig.Processes = []automationconfig.Process{} + } + setBaseUrlForAgents(automationConfig, opsManager.CentralURL()) + }). + AddProcessModification(func(i int, p *automationconfig.Process) { + p.AuthSchemaVersion = om.CalculateAuthSchemaVersion(rs.GetMongoDBVersion()) + p.Args26 = objx.New(rs.AdditionalMongodConfig.ToMap()) + p.SetPort(int(rs.AdditionalMongodConfig.GetPortOrDefault())) + p.SetReplicaSetName(rs.Name()) + p.SetSystemLog(automationconfig.SystemLog{ + Destination: "file", + Path: path.Join(util.PvcMountPathLogs, "mongodb.log"), + }) + p.SetStoragePath(automationconfig.DefaultMongoDBDataDir) + if rs.Security.IsTLSEnabled() { + + certFileName := certHash + if certFileName == "" { + certFileName = fmt.Sprintf("%s-pem", p.Name) + } + certFile := fmt.Sprintf("%s/certs/%s", util.SecretVolumeMountPath, certFileName) + + p.Args26.Set("net.tls.mode", string(tls.Require)) + + p.Args26.Set("net.tls.certificateKeyFile", certFile) + + } + }). + AddModifications(prometheusModification). + Build() + + if err != nil { + return automationconfig.AutomationConfig{}, err + } + + if acType == automation && opsManager.Spec.AppDB.AutomationConfigOverride != nil { + acToMerge := overrideToAutomationConfig(*opsManager.Spec.AppDB.AutomationConfigOverride) + ac = merge.AutomationConfigs(ac, acToMerge) + } + + return ac, nil + +} + +// buildPrometheusModification returns a `Modification` function that will add a +// `prometheus` entry to the Automation Config if Prometheus has been enabled on +// the Application Database (`spec.applicationDatabase.Prometheus`). +func buildPrometheusModification(sClient secrets.SecretClient, om omv1.MongoDBOpsManager, prometheusCertHash string) (automationconfig.Modification, error) { + if om.Spec.AppDB.Prometheus == nil { + return automationconfig.NOOP(), nil + } + + prom := om.Spec.AppDB.Prometheus + + var err error + var password string + prometheus := om.Spec.AppDB.Prometheus + + secretName := prometheus.PasswordSecretRef.Name + if vault.IsVaultSecretBackend() { + operatorSecretPath := sClient.VaultClient.OperatorSecretPath() + passwordString := fmt.Sprintf("%s/%s/%s", operatorSecretPath, om.GetNamespace(), secretName) + keyedPassword, err := sClient.VaultClient.ReadSecretString(passwordString) + if err != nil { + return automationconfig.NOOP(), err + } + + var ok bool + password, ok = keyedPassword[prometheus.GetPasswordKey()] + if !ok { + errMsg := fmt.Sprintf("Prometheus password %s not in Secret %s", prometheus.GetPasswordKey(), passwordString) + return automationconfig.NOOP(), xerrors.Errorf(errMsg) + } + } else { + secretNamespacedName := types.NamespacedName{Name: secretName, Namespace: om.Namespace} + password, err = secret.ReadKey(sClient, prometheus.GetPasswordKey(), secretNamespacedName) + if err != nil { + return automationconfig.NOOP(), err + } + } + + return func(config *automationconfig.AutomationConfig) { + promConfig := automationconfig.NewDefaultPrometheus(prom.Username) + + if prometheusCertHash != "" { + promConfig.TLSPemPath = util.SecretVolumeMountPathPrometheus + "/" + prometheusCertHash + promConfig.Scheme = "https" + } else { + promConfig.Scheme = "http" + } + + promConfig.Password = password + + if prom.Port > 0 { + promConfig.ListenAddress = fmt.Sprintf("%s:%d", mdbcv1_controllers.ListenAddress, prom.Port) + } + + if prom.MetricsPath != "" { + promConfig.MetricsPath = prom.MetricsPath + } + + config.Prometheus = &promConfig + }, nil +} + +// overrideToAutomationConfig converts the override struct to one that can be merged. +// for now only the disabled field is merged. +func overrideToAutomationConfig(override mdbcv1.AutomationConfigOverride) automationconfig.AutomationConfig { + var processes []automationconfig.Process + for _, p := range override.Processes { + processes = append(processes, automationconfig.Process{Name: p.Name, Disabled: p.Disabled}) + } + return automationconfig.AutomationConfig{Processes: processes} +} + +// setBaseUrlForAgents will update the baseUrl for all backup and monitoring versions to the provided url. +func setBaseUrlForAgents(ac *automationconfig.AutomationConfig, url string) { + for i := range ac.MonitoringVersions { + ac.MonitoringVersions[i].BaseUrl = url + } + for i := range ac.BackupVersions { + ac.BackupVersions[i].BaseUrl = url + } +} + +func addMonitoring(ac *automationconfig.AutomationConfig, log *zap.SugaredLogger, tls bool) { + if len(ac.Processes) == 0 { + return + } + monitoringVersions := ac.MonitoringVersions + for _, p := range ac.Processes { + found := false + for _, m := range monitoringVersions { + if m.Hostname == p.HostName { + found = true + break + } + } + if !found { + monitoringVersion := automationconfig.MonitoringVersion{ + Hostname: p.HostName, + Name: om.MonitoringAgentDefaultVersion, + } + if tls { + additionalParams := map[string]string{ + "useSslForAllConnections": "true", + "sslTrustedServerCertificates": appdbCAFilePath, + } + pemKeyFile := p.Args26.Get("net.tls.certificateKeyFile") + if pemKeyFile != nil { + additionalParams["sslClientCertificate"] = pemKeyFile.String() + } + monitoringVersion.AdditionalParams = additionalParams + } + log.Debugw("Added monitoring agent configuration", "host", p.HostName, "tls", tls) + monitoringVersions = append(monitoringVersions, monitoringVersion) + } + } + ac.MonitoringVersions = monitoringVersions +} + +// registerAppDBHostsWithProject uses the Hosts API to add each process in the AppBD to the project +func (r *ReconcileAppDbReplicaSet) registerAppDBHostsWithProject(opsManager *omv1.MongoDBOpsManager, conn om.Connection, opsManagerPassword string, log *zap.SugaredLogger) error { + appDbStatefulSet, err := r.client.GetStatefulSet(kube.ObjectKey(opsManager.Namespace, opsManager.Spec.AppDB.Name())) + if err != nil { + return err + } + + hostnames, _ := dns.GetDnsForStatefulSet(appDbStatefulSet, opsManager.Spec.AppDB.GetClusterDomain(), nil) + getHostsResult, err := conn.GetHosts() + if err != nil { + return xerrors.Errorf("error fetching existing hosts: %w", err) + } + + hostMap := make(map[string]host.Host) + for _, host := range getHostsResult.Results { + hostMap[host.Hostname] = host + } + + for _, hostname := range hostnames { + appDbHost := host.Host{ + Port: util.MongoDbDefaultPort, + Username: util.OpsManagerMongoDBUserName, + Password: opsManagerPassword, + Hostname: hostname, + AuthMechanismName: "MONGODB_CR", + } + + if currentHost, ok := hostMap[hostname]; ok { + // Host is already on the list, we need to update it. + log.Debugf("Host %s is already registred with group %s", hostname, conn.GroupID()) + // Need to se the Id first + appDbHost.Id = currentHost.Id + + if err := conn.UpdateHost(appDbHost); err != nil { + return xerrors.Errorf("error updating appdb host %w", err) + } + } else { + // This is a new host. + log.Debugf("Registering AppDB host %s with project %s", hostname, conn.GroupID()) + if err := conn.AddHost(appDbHost); err != nil { + return xerrors.Errorf("*** error adding appdb host %w", err) + } + } + } + return nil +} + +func (r OpsManagerReconciler) generatePasswordAndCreateSecret(opsManager omv1.MongoDBOpsManager, log *zap.SugaredLogger) (string, error) { + // create the password + password, err := generate.RandomFixedLengthStringOfSize(12) + if err != nil { + return "", err + } + + passwordData := map[string]string{ + util.OpsManagerPasswordKey: password, + } + + secretObjectKey := kube.ObjectKey(opsManager.Namespace, opsManager.Spec.AppDB.GetOpsManagerUserPasswordSecretName()) + + log.Infof("Creating mongodb-ops-manager password in secret/%s in namespace %s", secretObjectKey.Name, secretObjectKey.Namespace) + + appDbPasswordSecret := secret.Builder(). + SetName(secretObjectKey.Name). + SetNamespace(secretObjectKey.Namespace). + SetStringMapToData(passwordData). + SetOwnerReferences(kube.BaseOwnerReference(&opsManager)). + Build() + + if err := r.CreateSecret(appDbPasswordSecret); err != nil { + return "", err + } + + return password, nil +} + +// ensureAppDbPassword will return the password that was specified by the user, or the auto generated password stored in +// the secret (generate it and store in secret otherwise) +func (r OpsManagerReconciler) ensureAppDbPassword(opsManager omv1.MongoDBOpsManager, log *zap.SugaredLogger) (string, error) { + passwordRef := opsManager.Spec.AppDB.PasswordSecretKeyRef + if passwordRef != nil && passwordRef.Name != "" { // there is a secret specified for the Ops Manager user + if passwordRef.Key == "" { + passwordRef.Key = "password" + } + password, err := secret.ReadKey(r.SecretClient, passwordRef.Key, kube.ObjectKey(opsManager.Namespace, passwordRef.Name)) + + if err != nil { + if secrets.SecretNotExist(err) { + log.Debugf("Generated AppDB password and storing in secret/%s", opsManager.Spec.AppDB.GetOpsManagerUserPasswordSecretName()) + return r.generatePasswordAndCreateSecret(opsManager, log) + } + return "", err + } + + log.Debugf("Reading password from secret/%s", passwordRef.Name) + + // watch for any changes on the user provided password + r.AddWatchedResourceIfNotAdded( + passwordRef.Name, + opsManager.Namespace, + watch.Secret, + kube.ObjectKeyFromApiObject(&opsManager), + ) + + // delete the auto generated password, we don't need it anymore. We can just generate a new one if + // the user password is deleted + log.Debugf("Deleting Operator managed password secret/%s from namespace", opsManager.Spec.AppDB.GetOpsManagerUserPasswordSecretName(), opsManager.Namespace) + if err := r.DeleteSecret(kube.ObjectKey(opsManager.Namespace, opsManager.Spec.AppDB.GetOpsManagerUserPasswordSecretName())); err != nil && !secrets.SecretNotExist(err) { + return "", err + } + return password, nil + } + + // otherwise we'll ensure the auto generated password exists + secretObjectKey := kube.ObjectKey(opsManager.Namespace, opsManager.Spec.AppDB.GetOpsManagerUserPasswordSecretName()) + appDbPasswordSecretStringData, err := secret.ReadStringData(r.SecretClient, secretObjectKey) + + if secrets.SecretNotExist(err) { + // create the password + if password, err := r.generatePasswordAndCreateSecret(opsManager, log); err != nil { + return "", err + } else { + log.Debugf("Using auto generated AppDB password stored in secret/%s", opsManager.Spec.AppDB.GetOpsManagerUserPasswordSecretName()) + return password, nil + } + } else if err != nil { + // any other error + return "", err + } + log. + Debugf("Using auto generated AppDB password stored in secret/%s", opsManager.Spec.AppDB.GetOpsManagerUserPasswordSecretName()) + return appDbPasswordSecretStringData[util.OpsManagerPasswordKey], nil +} + +// ensureAppDbAgentApiKey makes sure there is an agent API key for the AppDB automation agent +func (r *ReconcileAppDbReplicaSet) ensureAppDbAgentApiKey(opsManager *omv1.MongoDBOpsManager, conn om.Connection, log *zap.SugaredLogger) error { + agentKeyFromSecret, err := secret.ReadKey(r.client, util.OmAgentApiKey, kube.ObjectKey(opsManager.Namespace, agents.ApiKeySecretName(conn.GroupID()))) + err = client.IgnoreNotFound(err) + if err != nil { + return xerrors.Errorf("error reading secret %s: %w", kube.ObjectKey(opsManager.Namespace, agents.ApiKeySecretName(conn.GroupID())), err) + } + var appdbSecretPath string + if r.VaultClient != nil { + appdbSecretPath = r.VaultClient.AppDBSecretPath() + } + if err := agents.EnsureAgentKeySecretExists(r.SecretClient, conn, opsManager.Namespace, agentKeyFromSecret, conn.GroupID(), appdbSecretPath, log); err != nil { + return xerrors.Errorf("error ensuring agent key secret exists: %w", err) + } + + return nil +} + +// tryConfigureMonitoringInOpsManager attempts to configure monitoring in Ops Manager. This might not be possible if Ops Manager +// has not been created yet, if that is the case, an empty PodVars will be returned. +func (r *ReconcileAppDbReplicaSet) tryConfigureMonitoringInOpsManager(opsManager *omv1.MongoDBOpsManager, opsManagerUserPassword string, log *zap.SugaredLogger) (env.PodEnvVars, error) { + var operatorVaultSecretPath string + if r.VaultClient != nil { + operatorVaultSecretPath = r.VaultClient.OperatorSecretPath() + } + + APIKeySecretName, err := opsManager.APIKeySecretName(r.SecretClient, operatorVaultSecretPath) + if err != nil { + return env.PodEnvVars{}, xerrors.Errorf("error getting opsManager secret name: %w", err) + } + + cred, err := project.ReadCredentials(r.SecretClient, kube.ObjectKey(operatorNamespace(), APIKeySecretName), log) + if err != nil { + log.Debugf("Ops Manager has not yet been created, not configuring monitoring: %s", err) + return env.PodEnvVars{}, nil + } + log.Debugf("Ensuring monitoring of AppDB is configured in Ops Manager") + + existingPodVars, err := r.readExistingPodVars(*opsManager, log) + if client.IgnoreNotFound(err) != nil { + return env.PodEnvVars{}, xerrors.Errorf("error reading existing podVars: %w", err) + } + + projectConfig, err := opsManager.GetAppDBProjectConfig(r.SecretClient) + if err != nil { + return existingPodVars, xerrors.Errorf("error getting existing project config: %w", err) + } + + _, conn, err := project.ReadOrCreateProject(projectConfig, cred, r.omConnectionFactory, log) + if err != nil { + return existingPodVars, xerrors.Errorf("error reading/creating project: %w", err) + } + + // Configure Authentication Options. + opts := authentication.Options{ + AgentMechanism: util.SCRAM, + Mechanisms: []string{util.SCRAM}, + ClientCertificates: util.OptionalClientCertficates, + AutoUser: util.AutomationAgentUserName, + CAFilePath: util.CAFilePathInContainer, + } + err = authentication.Configure(conn, opts, log) + if err != nil { + log.Errorf("Could not set Automation Authentication options in Ops/Cloud Manager for the Application Database. "+ + "Application Database is always configured with authentication enabled, but this will not be "+ + "visible from Ops/Cloud Manager UI. %s", err) + } + + err = conn.ReadUpdateDeployment(func(d om.Deployment) error { + d.ConfigureTLS(opsManager.Spec.AppDB.GetSecurity(), util.CAFilePathInContainer) + return nil + }, log) + if err != nil { + log.Errorf("Could not set TLS configuration in Ops/Cloud Manager for the Application Database. "+ + "Application Database has been configured with TLS enabled, but this will not be "+ + "visible from Ops/Cloud Manager UI. %s", err) + } + + if err := r.registerAppDBHostsWithProject(opsManager, conn, opsManagerUserPassword, log); err != nil { + return existingPodVars, xerrors.Errorf("error registering hosts with project: %w", err) + } + + if err := r.ensureAppDbAgentApiKey(opsManager, conn, log); err != nil { + return existingPodVars, xerrors.Errorf("error ensuring AppDB agent api key: %w", err) + } + + if err := markAppDBAsBackingProject(conn, log); err != nil { + return existingPodVars, xerrors.Errorf("error marking project has backing db: %w", err) + } + + cm := configmap.Builder(). + SetName(opsManager.Spec.AppDB.ProjectIDConfigMapName()). + SetNamespace(opsManager.Namespace). + SetDataField(util.AppDbProjectIdKey, conn.GroupID()). + Build() + + // Saving the "backup" ConfigMap which contains the project id + if err := configmap.CreateOrUpdate(r.client, cm); err != nil { + return existingPodVars, xerrors.Errorf("error creating ConfigMap: %w", err) + } + + return env.PodEnvVars{User: conn.PublicKey(), ProjectID: conn.GroupID(), SSLProjectConfig: env.SSLProjectConfig{ + SSLMMSCAConfigMap: opsManager.Spec.GetOpsManagerCA(), + }, + }, nil +} + +// readExistingPodVars is a backup function which provides the required podVars for the AppDB +// in the case of Ops Manager not being reachable. An example of when this is used is: +// 1. The AppDB starts as normal +// 2. Ops Manager starts as normal +// 3. The AppDB password was configured mid-reconciliation +// 4. AppDB reconciles and attempts to configure monitoring, but this is not possible +// as OM cannot currently connect to the AppDB as it has not yet been provided the updated password. +// In such a case, we cannot read the groupId from OM, so we fall back to the ConfigMap we created +// before hand. This is required as with empty PodVars this would trigger an unintentional +// rolling restart of the AppDB. +func (r *ReconcileAppDbReplicaSet) readExistingPodVars(om omv1.MongoDBOpsManager, log *zap.SugaredLogger) (env.PodEnvVars, error) { + cm, err := r.client.GetConfigMap(kube.ObjectKey(om.Namespace, om.Spec.AppDB.ProjectIDConfigMapName())) + if err != nil { + return env.PodEnvVars{}, err + } + var projectId string + if projectId = cm.Data[util.AppDbProjectIdKey]; projectId == "" { + return env.PodEnvVars{}, xerrors.Errorf("ConfigMap %s did not have the key %s", om.Spec.AppDB.ProjectIDConfigMapName(), util.AppDbProjectIdKey) + } + + var operatorVaultSecretPath string + if r.VaultClient != nil { + operatorVaultSecretPath = r.VaultClient.OperatorSecretPath() + } + APISecretName, err := om.APIKeySecretName(r.SecretClient, operatorVaultSecretPath) + if err != nil { + return env.PodEnvVars{}, xerrors.Errorf("error getting ops-manager API secret name: %w", err) + } + + cred, err := project.ReadCredentials(r.SecretClient, kube.ObjectKey(operatorNamespace(), APISecretName), log) + if err != nil { + return env.PodEnvVars{}, xerrors.Errorf("error reading credentials: %w", err) + } + + return env.PodEnvVars{ + User: cred.PublicAPIKey, + ProjectID: projectId, + SSLProjectConfig: env.SSLProjectConfig{ + SSLMMSCAConfigMap: om.Spec.GetOpsManagerCA(), + }, + }, nil +} + +func (r *ReconcileAppDbReplicaSet) publishACVersionAsConfigMap(cmName string, namespace string, version int) workflow.Status { + acVersionConfigMap := configmap.Builder(). + SetNamespace(namespace). + SetName(cmName). + SetDataField("version", fmt.Sprintf("%d", version)). + Build() + if err := configmap.CreateOrUpdate(r.client, acVersionConfigMap); err != nil { + return workflow.Failed(err) + } + return workflow.OK() +} + +// deployAutomationConfig updates the Automation Config secret if necessary and waits for the pods to fall to "not ready" +// In this case the next StatefulSet update will be safe as the rolling upgrade will wait for the pods to get ready +func (r *ReconcileAppDbReplicaSet) deployAutomationConfig(opsManager omv1.MongoDBOpsManager, appDbSts appsv1.StatefulSet, prometheusCertHash string, log *zap.SugaredLogger) workflow.Status { + + rs := opsManager.Spec.AppDB + + config, err := r.buildAppDbAutomationConfig(opsManager, appDbSts, automation, prometheusCertHash, log) + if err != nil { + return workflow.Failed(err) + } + + var configVersion int + if configVersion, err = r.publishAutomationConfig(opsManager, config, rs.AutomationConfigSecretName()); err != nil { + return workflow.Failed(err) + } + + if status := r.publishACVersionAsConfigMap(opsManager.Spec.AppDB.AutomationConfigConfigMapName(), opsManager.Namespace, configVersion); !status.IsOK() { + return status + } + + monitoringAc, err := r.buildAppDbAutomationConfig(opsManager, appDbSts, monitoring, UnusedPrometheusConfiguration, log) + if err != nil { + return workflow.Failed(err) + } + + if err := r.deployMonitoringAgentAutomationConfig(opsManager, appDbSts, log); err != nil { + return workflow.Failed(err) + } + if status := r.publishACVersionAsConfigMap(opsManager.Spec.AppDB.MonitoringAutomationConfigConfigMapName(), opsManager.Namespace, monitoringAc.Version); !status.IsOK() { + return status + } + + return r.allAgentsReachedGoalState(opsManager, configVersion, log) +} + +// deployMonitoringAgentAutomationConfig deploys the monitoring agent's automation config. +func (r *ReconcileAppDbReplicaSet) deployMonitoringAgentAutomationConfig(opsManager omv1.MongoDBOpsManager, appDbSts appsv1.StatefulSet, log *zap.SugaredLogger) error { + config, err := r.buildAppDbAutomationConfig(opsManager, appDbSts, monitoring, UnusedPrometheusConfiguration, log) + if err != nil { + return err + } + if _, err = r.publishAutomationConfig(opsManager, config, opsManager.Spec.AppDB.MonitoringAutomationConfigSecretName()); err != nil { + return err + } + return nil +} + +// deployStatefulSet updates the StatefulSet spec and returns its status (if it's ready or not) +func (r *ReconcileAppDbReplicaSet) deployStatefulSet(opsManager omv1.MongoDBOpsManager, appDbSts appsv1.StatefulSet, log *zap.SugaredLogger) workflow.Status { + + if err := create.AppDBInKubernetes(r.client, opsManager, appDbSts, log); err != nil { + return workflow.Failed(err) + + } + + return getStatefulSetStatus(opsManager.Namespace, opsManager.Spec.AppDB.Name(), r.client) +} + +// allAgentsReachedGoalState checks if all the AppDB Agents have reached the goal state. +func (r *ReconcileAppDbReplicaSet) allAgentsReachedGoalState(manager omv1.MongoDBOpsManager, targetConfigVersion int, log *zap.SugaredLogger) workflow.Status { + // We need to read the current StatefulSet to find the real number of pods - we cannot rely on OpsManager resource + set, err := r.client.GetStatefulSet(manager.AppDBStatefulSetObjectKey()) + if err != nil { + if apiErrors.IsNotFound(err) { + // If the StatefulSet could not be found, do not check agents during this reconcile. + return workflow.OK() + } + return workflow.Failed(err) + } + + appdbSize := int(set.Status.Replicas) + goalState, err := agent.AllReachedGoalState(set, r.client, appdbSize, targetConfigVersion, log) + if err != nil { + return workflow.Failed(err) + } + if goalState { + return workflow.OK() + } + return workflow.Pending("Application Database Agents haven't reached Running state yet") +} + +// markAppDBAsBackingProject will configure the AppDB project to be read only. Errors are ignored +// if the OpsManager version does not support this feature. +func markAppDBAsBackingProject(conn om.Connection, log *zap.SugaredLogger) error { + log.Debugf("Configuring the project as a backing database project.") + err := conn.MarkProjectAsBackingDatabase(om.AppDBDatabaseType) + if err != nil { + if apiErr, ok := err.(*apierror.Error); ok { + opsManagerDoesNotSupportApi := apiErr.Status != nil && *apiErr.Status == 404 && apiErr.ErrorCode == "RESOURCE_NOT_FOUND" + if opsManagerDoesNotSupportApi { + msg := "This version of Ops Manager does not support the markAsBackingDatabase API." + if !conn.OpsManagerVersion().IsUnknown() { + msg += fmt.Sprintf(" Version=%s", conn.OpsManagerVersion()) + } + log.Debug(msg) + return nil + } + } + return err + } + return nil +} diff --git a/controllers/operator/appdbreplicaset_controller_test.go b/controllers/operator/appdbreplicaset_controller_test.go new file mode 100644 index 000000000..794b25fae --- /dev/null +++ b/controllers/operator/appdbreplicaset_controller_test.go @@ -0,0 +1,729 @@ +package operator + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "testing" + "time" + + mdbcv1 "github.com/mongodb/mongodb-kubernetes-operator/api/v1" + appsv1 "k8s.io/api/apps/v1" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/agents" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/connectionstring" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/construct" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/automationconfig" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/statefulset" + + "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + + "k8s.io/apimachinery/pkg/types" + + "k8s.io/apimachinery/pkg/api/resource" + + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/secret" + + omv1 "github.com/10gen/ops-manager-kubernetes/api/v1/om" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/mock" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" + + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/stretchr/testify/assert" + "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/manager" +) + +func init() { + mock.InitDefaultEnvVariables() +} + +func TestMongoDB_ConnectionURL_DefaultCluster_AppDB(t *testing.T) { + opsManager := DefaultOpsManagerBuilder().Build() + appdb := &opsManager.Spec.AppDB + + var cnx string + cnx = appdb.BuildConnectionURL("user", "passwd", connectionstring.SchemeMongoDB, nil) + assert.Equal(t, "mongodb://user:passwd@test-om-db-0.test-om-db-svc.my-namespace.svc.cluster.local:27017,"+ + "test-om-db-1.test-om-db-svc.my-namespace.svc.cluster.local:27017,test-om-db-2.test-om-db-svc.my-namespace.svc.cluster.local:27017/"+ + "?authMechanism=SCRAM-SHA-256&authSource=admin&connectTimeoutMS=20000&replicaSet=test-om-db&serverSelectionTimeoutMS=20000", cnx) + + // Special symbols in the url + cnx = appdb.BuildConnectionURL("special/user#", "@passw!", connectionstring.SchemeMongoDB, nil) + assert.Equal(t, "mongodb://special%2Fuser%23:%40passw%21@test-om-db-0.test-om-db-svc.my-namespace.svc.cluster.local:27017,"+ + "test-om-db-1.test-om-db-svc.my-namespace.svc.cluster.local:27017,test-om-db-2.test-om-db-svc.my-namespace.svc.cluster.local:27017/"+ + "?authMechanism=SCRAM-SHA-256&authSource=admin&connectTimeoutMS=20000&replicaSet=test-om-db&serverSelectionTimeoutMS=20000", cnx) + + // Connection parameters. The default one is overridden + cnx = appdb.BuildConnectionURL("user", "passwd", connectionstring.SchemeMongoDB, map[string]string{"connectTimeoutMS": "30000", "readPreference": "secondary"}) + assert.Equal(t, "mongodb://user:passwd@test-om-db-0.test-om-db-svc.my-namespace.svc.cluster.local:27017,"+ + "test-om-db-1.test-om-db-svc.my-namespace.svc.cluster.local:27017,test-om-db-2.test-om-db-svc.my-namespace.svc.cluster.local:27017/"+ + "?authMechanism=SCRAM-SHA-256&authSource=admin&connectTimeoutMS=30000&readPreference=secondary&replicaSet=test-om-db&serverSelectionTimeoutMS=20000", + cnx) +} + +func TestMongoDB_ConnectionURL_OtherCluster_AppDB(t *testing.T) { + opsManager := DefaultOpsManagerBuilder().SetClusterDomain("my-cluster").Build() + appdb := &opsManager.Spec.AppDB + + var cnx string + cnx = appdb.BuildConnectionURL("user", "passwd", connectionstring.SchemeMongoDB, nil) + assert.Equal(t, "mongodb://user:passwd@test-om-db-0.test-om-db-svc.my-namespace.svc.my-cluster:27017,"+ + "test-om-db-1.test-om-db-svc.my-namespace.svc.my-cluster:27017,test-om-db-2.test-om-db-svc.my-namespace.svc.my-cluster:27017/"+ + "?authMechanism=SCRAM-SHA-256&authSource=admin&connectTimeoutMS=20000&replicaSet=test-om-db&serverSelectionTimeoutMS=20000", cnx) + + // Connection parameters. The default one is overridden + cnx = appdb.BuildConnectionURL("user", "passwd", connectionstring.SchemeMongoDB, map[string]string{"connectTimeoutMS": "30000", "readPreference": "secondary"}) + assert.Equal(t, "mongodb://user:passwd@test-om-db-0.test-om-db-svc.my-namespace.svc.my-cluster:27017,"+ + "test-om-db-1.test-om-db-svc.my-namespace.svc.my-cluster:27017,test-om-db-2.test-om-db-svc.my-namespace.svc.my-cluster:27017/"+ + "?authMechanism=SCRAM-SHA-256&authSource=admin&connectTimeoutMS=30000&readPreference=secondary&replicaSet=test-om-db&serverSelectionTimeoutMS=20000", + cnx) +} + +// TestAutomationConfig_IsCreatedInSecret verifies that the automation config is created in a secret. +func TestAutomationConfig_IsCreatedInSecret(t *testing.T) { + builder := DefaultOpsManagerBuilder() + opsManager := builder.Build() + appdb := opsManager.Spec.AppDB + kubeManager := mock.NewManager(&opsManager) + reconciler := newAppDbReconciler(kubeManager) + + err := createOpsManagerUserPasswordSecret(kubeManager.Client, opsManager, "MBPYfkAj5ZM0l9uw6C7ggw") + assert.NoError(t, err) + _, err = reconciler.ReconcileAppDB(&opsManager, "MBPYfkAj5ZM0l9uw6C7ggw") + assert.NoError(t, err) + + s, err := kubeManager.Client.GetSecret(kube.ObjectKey(opsManager.Namespace, appdb.AutomationConfigSecretName())) + assert.NoError(t, err, "The Automation Config was created in a secret.") + assert.Contains(t, s.Data, automationconfig.ConfigKey) +} + +// TestPublishAutomationConfig_Create verifies that the automation config map is created if it doesn't exist +func TestPublishAutomationConfig_Create(t *testing.T) { + builder := DefaultOpsManagerBuilder() + opsManager := builder.Build() + appdb := opsManager.Spec.AppDB + kubeManager := mock.NewEmptyManager() + reconciler := newAppDbReconciler(kubeManager) + automationConfig, err := buildAutomationConfigForAppDb(builder, kubeManager, automation) + assert.NoError(t, err) + version, err := reconciler.publishAutomationConfig(opsManager, automationConfig, appdb.AutomationConfigSecretName()) + assert.NoError(t, err) + assert.Equal(t, 1, version) + + monitoringAutomationConfig, err := buildAutomationConfigForAppDb(builder, kubeManager, monitoring) + assert.NoError(t, err) + version, err = reconciler.publishAutomationConfig(opsManager, monitoringAutomationConfig, appdb.MonitoringAutomationConfigSecretName()) + assert.NoError(t, err) + assert.Equal(t, 1, version) + + // verify the automation config secret for the automation agent + acSecret := readAutomationConfigSecret(t, kubeManager, opsManager) + checkDeploymentEqualToPublished(t, automationConfig, acSecret) + + // verify the automation config secret for the monitoring agent + acMonitoringSecret := readAutomationConfigMonitoringSecret(t, kubeManager, opsManager) + checkDeploymentEqualToPublished(t, monitoringAutomationConfig, acMonitoringSecret) + + assert.Len(t, kubeManager.Client.GetMapForObject(&corev1.Secret{}), 6) + + _, err = kubeManager.Client.GetSecret(kube.ObjectKey(opsManager.Namespace, appdb.GetOpsManagerUserPasswordSecretName())) + assert.NoError(t, err) + + _, err = kubeManager.Client.GetSecret(kube.ObjectKey(opsManager.Namespace, appdb.GetAgentKeyfileSecretNamespacedName().Name)) + assert.NoError(t, err) + + _, err = kubeManager.Client.GetSecret(kube.ObjectKey(opsManager.Namespace, appdb.GetAgentPasswordSecretNamespacedName().Name)) + assert.NoError(t, err) + + _, err = kubeManager.Client.GetSecret(kube.ObjectKey(opsManager.Namespace, appdb.OpsManagerUserScramCredentialsName())) + assert.NoError(t, err) + + _, err = kubeManager.Client.GetSecret(kube.ObjectKey(opsManager.Namespace, appdb.AutomationConfigSecretName())) + assert.NoError(t, err) + + _, err = kubeManager.Client.GetSecret(kube.ObjectKey(opsManager.Namespace, appdb.MonitoringAutomationConfigSecretName())) + assert.NoError(t, err) + + // verifies Users and Roles are created + assert.Len(t, automationConfig.Auth.Users, 1) + + expectedRoles := []string{"readWriteAnyDatabase", "dbAdminAnyDatabase", "clusterMonitor", "backup", "restore", "hostManager"} + assert.Len(t, automationConfig.Auth.Users[0].Roles, len(expectedRoles)) + for idx, role := range expectedRoles { + assert.Equal(t, automationConfig.Auth.Users[0].Roles[idx], + automationconfig.Role{ + Role: role, + Database: "admin", + }) + } +} + +// TestPublishAutomationConfig_Update verifies that the automation config map is updated if it has changed +func TestPublishAutomationConfig_Update(t *testing.T) { + builder := DefaultOpsManagerBuilder() + opsManager := builder.Build() + appdb := opsManager.Spec.AppDB + kubeManager := mock.NewManager(&opsManager) + reconciler := newAppDbReconciler(kubeManager) + + // create + createOpsManagerUserPasswordSecret(kubeManager.Client, opsManager, "MBPYfkAj5ZM0l9uw6C7ggw") + _, err := reconciler.ReconcileAppDB(&opsManager, "MBPYfkAj5ZM0l9uw6C7ggw") + assert.NoError(t, err) + + ac, err := automationconfig.ReadFromSecret(reconciler.client, kube.ObjectKey(opsManager.Namespace, appdb.AutomationConfigSecretName())) + assert.NoError(t, err) + assert.Equal(t, 1, ac.Version) + + // publishing the config without updates should not result in API call + _, err = reconciler.ReconcileAppDB(&opsManager, "MBPYfkAj5ZM0l9uw6C7ggw") + assert.NoError(t, err) + + ac, err = automationconfig.ReadFromSecret(reconciler.client, kube.ObjectKey(opsManager.Namespace, appdb.AutomationConfigSecretName())) + assert.NoError(t, err) + assert.Equal(t, 1, ac.Version) + kubeManager.Client.CheckOperationsDidntHappen(t, mock.HItem(reflect.ValueOf(kubeManager.Client.Update), &corev1.Secret{})) + + // publishing changed config will result in update + fcv := "4.4" + opsManager.Spec.AppDB.FeatureCompatibilityVersion = &fcv + kubeManager.Client.Update(context.TODO(), &opsManager) + + _, err = reconciler.ReconcileAppDB(&opsManager, "MBPYfkAj5ZM0l9uw6C7ggw") + assert.NoError(t, err) + + ac, err = automationconfig.ReadFromSecret(reconciler.client, kube.ObjectKey(opsManager.Namespace, appdb.AutomationConfigSecretName())) + assert.NoError(t, err) + assert.Equal(t, 2, ac.Version) + kubeManager.Client.CheckOrderOfOperations(t, mock.HItem(reflect.ValueOf(kubeManager.Client.Update), &corev1.Secret{})) +} + +// TestBuildAppDbAutomationConfig checks that the automation config is built correctly +func TestBuildAppDbAutomationConfig(t *testing.T) { + builder := DefaultOpsManagerBuilder(). + SetAppDbMembers(2). + SetAppDbVersion("4.2.11-ent"). + SetAppDbFeatureCompatibility("4.0") + + om := builder.Build() + + manager := mock.NewManager(&om) + createOpsManagerUserPasswordSecret(manager.Client, om, "omPass") + + automationConfig, err := buildAutomationConfigForAppDb(builder, manager, automation) + assert.NoError(t, err) + monitoringAutomationConfig, err := buildAutomationConfigForAppDb(builder, manager, monitoring) + assert.NoError(t, err) + // processes + assert.Len(t, automationConfig.Processes, 2) + assert.Equal(t, "4.2.11-ent", automationConfig.Processes[0].Version) + assert.Equal(t, "test-om-db-0.test-om-db-svc.my-namespace.svc.cluster.local", automationConfig.Processes[0].HostName) + assert.Equal(t, "4.0", automationConfig.Processes[0].FeatureCompatibilityVersion) + assert.Equal(t, "4.2.11-ent", automationConfig.Processes[1].Version) + assert.Equal(t, "test-om-db-1.test-om-db-svc.my-namespace.svc.cluster.local", automationConfig.Processes[1].HostName) + assert.Equal(t, "4.0", automationConfig.Processes[1].FeatureCompatibilityVersion) + assert.Len(t, monitoringAutomationConfig.Processes, 0) + assert.Len(t, monitoringAutomationConfig.ReplicaSets, 0) + + // replicasets + assert.Len(t, automationConfig.ReplicaSets, 1) + db := builder.Build().Spec.AppDB + assert.Equal(t, db.Name(), automationConfig.ReplicaSets[0].Id) + + // monitoring agent has been configured + assert.Len(t, automationConfig.MonitoringVersions, 0) + + // backup agents have not been configured + assert.Len(t, automationConfig.BackupVersions, 0) + + // options + assert.Equal(t, automationconfig.Options{DownloadBase: util.AgentDownloadsDir}, automationConfig.Options) +} + +func TestRegisterAppDBHostsWithProject(t *testing.T) { + builder := DefaultOpsManagerBuilder() + opsManager := builder.Build() + kubeManager := mock.NewEmptyManager() + client := kubeManager.Client + reconciler := newAppDbReconciler(kubeManager) + conn := om.NewMockedOmConnection(om.NewDeployment()) + + appDbSts, err := construct.AppDbStatefulSet(opsManager, &env.PodEnvVars{ProjectID: "abcd"}, construct.AppDBStatefulSetOptions{}, nil) + assert.NoError(t, err) + + t.Run("Ensure all hosts are added", func(t *testing.T) { + + _ = client.Update(context.TODO(), &appDbSts) + + err := reconciler.registerAppDBHostsWithProject(&opsManager, conn, "password", zap.S()) + assert.NoError(t, err) + + hosts, _ := conn.GetHosts() + assert.Len(t, hosts.Results, 3) + }) + + t.Run("Ensure hosts are added when scaled up", func(t *testing.T) { + appDbSts.Spec.Replicas = util.Int32Ref(5) + _ = client.Update(context.TODO(), &appDbSts) + + err := reconciler.registerAppDBHostsWithProject(&opsManager, conn, "password", zap.S()) + assert.NoError(t, err) + + hosts, _ := conn.GetHosts() + assert.Len(t, hosts.Results, 5) + }) +} + +func TestEnsureAppDbAgentApiKey(t *testing.T) { + builder := DefaultOpsManagerBuilder() + opsManager := builder.Build() + kubeManager := mock.NewEmptyManager() + reconciler := newAppDbReconciler(kubeManager) + + conn := om.NewMockedOmConnection(om.NewDeployment()) + conn.AgentAPIKey = "my-api-key" + err := reconciler.ensureAppDbAgentApiKey(&opsManager, conn, zap.S()) + assert.NoError(t, err) + + secretName := agents.ApiKeySecretName(conn.GroupID()) + apiKey, err := secret.ReadKey(reconciler.client, util.OmAgentApiKey, kube.ObjectKey(opsManager.Namespace, secretName)) + assert.NoError(t, err) + assert.Equal(t, "my-api-key", apiKey) +} + +func TestTryConfigureMonitoringInOpsManager(t *testing.T) { + builder := DefaultOpsManagerBuilder() + opsManager := builder.Build() + kubeManager := mock.NewEmptyManager() + client := kubeManager.Client + reconciler := newAppDbReconciler(kubeManager) + + reconciler.omConnectionFactory = func(context *om.OMContext) om.Connection { + return om.NewEmptyMockedOmConnection(context) + } + + // attempt configuring monitoring when there is no api key secret + podVars, err := reconciler.tryConfigureMonitoringInOpsManager(&opsManager, "password", zap.S()) + assert.NoError(t, err) + + assert.Empty(t, podVars.ProjectID) + assert.Empty(t, podVars.User) + + opsManager.Spec.AppDB.Members = 5 + appDbSts, err := construct.AppDbStatefulSet(opsManager, &env.PodEnvVars{ProjectID: "abcd"}, construct.AppDBStatefulSetOptions{}, nil) + assert.NoError(t, err) + + _ = client.Update(context.TODO(), &appDbSts) + + data := map[string]string{ + util.OmPublicApiKey: "publicApiKey", + util.OmPrivateKey: "privateApiKey", + } + APIKeySecretName, err := opsManager.APIKeySecretName(client.MockedSecretClient, "") + assert.NoError(t, err) + + apiKeySecret := secret.Builder(). + SetNamespace(operatorNamespace()). + SetName(APIKeySecretName). + SetStringMapToData(data). + Build() + + err = reconciler.client.CreateSecret(apiKeySecret) + assert.NoError(t, err) + + // once the secret exists, monitoring should be fully configured + podVars, err = reconciler.tryConfigureMonitoringInOpsManager(&opsManager, "password", zap.S()) + assert.NoError(t, err) + + assert.Equal(t, om.TestGroupID, podVars.ProjectID) + assert.Equal(t, "publicApiKey", podVars.User) + + hosts, _ := om.CurrMockedConnection.GetHosts() + assert.Len(t, hosts.Results, 5, "the AppDB hosts should have been added") +} + +func TestAppDBScaleUp_HappensIncrementally(t *testing.T) { + performAppDBScalingTest(t, 1, 5) +} + +func TestAppDBScaleDown_HappensIncrementally(t *testing.T) { + performAppDBScalingTest(t, 5, 1) +} + +func TestAppDBScaleUp_HappensIncrementally_FullOpsManagerReconcile(t *testing.T) { + + opsManager := DefaultOpsManagerBuilder(). + SetBackup(omv1.MongoDBOpsManagerBackup{Enabled: false}). + SetAppDbMembers(1). + SetVersion("5.0.0"). + Build() + omReconciler, client, _ := defaultTestOmReconciler(t, opsManager) + + checkOMReconciliationSuccessful(t, omReconciler, &opsManager) + + err := client.Get(context.TODO(), types.NamespacedName{Name: opsManager.Name, Namespace: opsManager.Namespace}, &opsManager) + assert.NoError(t, err) + + opsManager.Spec.AppDB.Members = 3 + + err = client.Update(context.TODO(), &opsManager) + assert.NoError(t, err) + + checkOMReconciliationPending(t, omReconciler, &opsManager) + + err = client.Get(context.TODO(), types.NamespacedName{Name: opsManager.Name, Namespace: opsManager.Namespace}, &opsManager) + assert.NoError(t, err) + + assert.Equal(t, 2, opsManager.Status.AppDbStatus.Members) + + res, err := omReconciler.Reconcile(context.TODO(), requestFromObject(&opsManager)) + assert.NoError(t, err) + assert.Equal(t, time.Duration(0), res.RequeueAfter) + assert.Equal(t, false, res.Requeue) + + err = client.Get(context.TODO(), types.NamespacedName{Name: opsManager.Name, Namespace: opsManager.Namespace}, &opsManager) + assert.NoError(t, err) + + assert.Equal(t, 3, opsManager.Status.AppDbStatus.Members) + +} + +func TestAppDbPortIsConfigurable_WithAdditionalMongoConfig(t *testing.T) { + opsManager := DefaultOpsManagerBuilder(). + SetBackup(omv1.MongoDBOpsManagerBackup{Enabled: false}). + SetAppDbMembers(1). + SetAdditionalMongodbConfig(mdb.NewAdditionalMongodConfig("net.port", 30000)). + Build() + omReconciler, client, _ := defaultTestOmReconciler(t, opsManager) + + checkOMReconciliationSuccessful(t, omReconciler, &opsManager) + + appdbSvc, err := client.GetService(kube.ObjectKey(opsManager.Namespace, opsManager.Spec.AppDB.ServiceName())) + assert.NoError(t, err) + assert.Equal(t, int32(30000), appdbSvc.Spec.Ports[0].Port) +} + +func TestGetMonitoringAgentVersion(t *testing.T) { + jsonContents := ` +[ + {"ops_manager_version": "4.2", "agent_version": "version0"}, + {"ops_manager_version": "4.4", "agent_version": "version1"} +]` + t.Run("The version returned for the agent 4.2 when OM is 4.2", func(t *testing.T) { + opsManager := omv1.NewOpsManagerBuilderDefault().SetVersion("4.2.0").Build() + version, err := getMonitoringAgentVersion(opsManager, func(string) ([]byte, error) { + return []byte(jsonContents), nil + }) + assert.Nil(t, err) + assert.Equal(t, "version0", version) + }) + + t.Run("The version returned for the agent 4.4 when OM is 4.4", func(t *testing.T) { + opsManager := omv1.NewOpsManagerBuilderDefault().SetVersion("4.4.6").Build() + version, err := getMonitoringAgentVersion(opsManager, func(string) ([]byte, error) { + return []byte(jsonContents), nil + }) + assert.Nil(t, err) + assert.Equal(t, "version1", version) + }) + + t.Run("There is an error when the version is not present", func(t *testing.T) { + opsManager := omv1.NewOpsManagerBuilderDefault().SetVersion("4.0.6").Build() + _, err := getMonitoringAgentVersion(opsManager, func(string) ([]byte, error) { + return []byte(jsonContents), nil + }) + assert.Error(t, err) + }) +} + +func TestAppDBSkipsReconciliation_IfAnyProcessesAreDisabled(t *testing.T) { + + createReconcilerWithAllRequiredSecrets := func(opsManager omv1.MongoDBOpsManager, createAutomationConfig bool) *ReconcileAppDbReplicaSet { + kubeManager := mock.NewEmptyManager() + err := createOpsManagerUserPasswordSecret(kubeManager.Client, opsManager, "my-password") + assert.NoError(t, err) + reconciler := newAppDbReconciler(kubeManager) + reconciler.client = kubeManager.Client + + // create a pre-existing automation config based on the resource provided. + // if the automation is not there, we will always want to reconcile. Otherwise, we may not reconcile + // based on whether or not there are disabled processes. + if createAutomationConfig { + ac, err := reconciler.buildAppDbAutomationConfig(opsManager, appsv1.StatefulSet{}, automation, UnusedPrometheusConfiguration, zap.S()) + assert.NoError(t, err) + _, err = reconciler.publishAutomationConfig(opsManager, ac, opsManager.Spec.AppDB.AutomationConfigSecretName()) + assert.NoError(t, err) + } + return reconciler + } + + t.Run("Reconciliation should happen if we are disabling a process", func(t *testing.T) { + + // In this test, we create an OM + automation config (with no disabled processes), + //then update OM to have a disabled processes, and we assert that reconciliation should take place. + + omName := "test-om" + opsManager := DefaultOpsManagerBuilder().SetName(omName).Build() + + reconciler := createReconcilerWithAllRequiredSecrets(opsManager, true) + + opsManager = DefaultOpsManagerBuilder().SetName(omName).SetAppDBAutomationConfigOverride(mdbcv1.AutomationConfigOverride{ + Processes: []mdbcv1.OverrideProcess{ + { + // disable the process + Name: fmt.Sprintf("%s-db-0", omName), + Disabled: true, + }, + }, + }).Build() + + shouldReconcile, err := reconciler.shouldReconcileAppDB(opsManager, zap.S()) + assert.NoError(t, err) + assert.True(t, shouldReconcile) + }) + + t.Run("Reconciliation should not happen if a process is disabled", func(t *testing.T) { + // In this test, we create an OM with a disabled process, and assert that a reconciliation + //should not take place (since we are not changing a process back from disabled). + + omName := "test-om" + opsManager := DefaultOpsManagerBuilder().SetName(omName).SetAppDBAutomationConfigOverride(mdbcv1.AutomationConfigOverride{ + Processes: []mdbcv1.OverrideProcess{ + { + // disable the process + Name: fmt.Sprintf("%s-db-0", omName), + Disabled: true, + }, + }, + }).Build() + + reconciler := createReconcilerWithAllRequiredSecrets(opsManager, true) + + shouldReconcile, err := reconciler.shouldReconcileAppDB(opsManager, zap.S()) + assert.NoError(t, err) + assert.False(t, shouldReconcile) + }) + + t.Run("Reconciliation should happen if no automation config is present", func(t *testing.T) { + omName := "test-om" + opsManager := DefaultOpsManagerBuilder().SetName(omName).SetAppDBAutomationConfigOverride(mdbcv1.AutomationConfigOverride{ + Processes: []mdbcv1.OverrideProcess{ + { + // disable the process + Name: fmt.Sprintf("%s-db-0", omName), + Disabled: true, + }, + }, + }).Build() + + reconciler := createReconcilerWithAllRequiredSecrets(opsManager, false) + + shouldReconcile, err := reconciler.shouldReconcileAppDB(opsManager, zap.S()) + assert.NoError(t, err) + assert.True(t, shouldReconcile) + }) + + t.Run("Reconciliation should happen we are re-enabling a process", func(t *testing.T) { + omName := "test-om" + opsManager := DefaultOpsManagerBuilder().SetName(omName).SetAppDBAutomationConfigOverride(mdbcv1.AutomationConfigOverride{ + Processes: []mdbcv1.OverrideProcess{ + { + // disable the process + Name: fmt.Sprintf("%s-db-0", omName), + Disabled: true, + }, + }, + }).Build() + + reconciler := createReconcilerWithAllRequiredSecrets(opsManager, true) + + opsManager = DefaultOpsManagerBuilder().SetName(omName).Build() + + shouldReconcile, err := reconciler.shouldReconcileAppDB(opsManager, zap.S()) + assert.NoError(t, err) + assert.True(t, shouldReconcile) + }) + +} + +// appDBStatefulSetLabelsAndServiceName returns extra fields that we have to manually set to the AppDB statefulset +// since we manually create it. Otherwise the tests will fail as we try to update parts of the sts that we are not +// allowed to change +func appDBStatefulSetLabelsAndServiceName(omResourceName string) (map[string]string, string) { + appDbName := fmt.Sprintf("%s-db", omResourceName) + serviceName := fmt.Sprintf("%s-svc", appDbName) + labels := map[string]string{"app": serviceName, "controller": "mongodb-enterprise-operator", "pod-anti-affinity": appDbName} + return labels, serviceName +} + +func appDBStatefulSetVolumeClaimtemplates() []corev1.PersistentVolumeClaim { + + resData, _ := resource.ParseQuantity("16G") + return []corev1.PersistentVolumeClaim{{ + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{"ReadWriteOnce"}, + Resources: corev1.ResourceRequirements{ + Requests: map[corev1.ResourceName]resource.Quantity{"storage": resData}, + }, + }}, + } +} + +func performAppDBScalingTest(t *testing.T, startingMembers, finalMembers int) { + builder := DefaultOpsManagerBuilder().SetAppDbMembers(startingMembers) + opsManager := builder.Build() + kubeManager := mock.NewEmptyManager() + client := kubeManager.Client + createOpsManagerUserPasswordSecret(client, opsManager, "pass") + reconciler := newAppDbReconciler(kubeManager) + + // create the apiKey and OM user + data := map[string]string{ + util.OmPublicApiKey: "publicApiKey", + util.OmPrivateKey: "privateApiKey", + } + + APIKeySecretName, err := opsManager.APIKeySecretName(client, "") + assert.NoError(t, err) + + apiKeySecret := secret.Builder(). + SetNamespace(operatorNamespace()). + SetName(APIKeySecretName). + SetStringMapToData(data). + Build() + + err = reconciler.client.CreateSecret(apiKeySecret) + assert.NoError(t, err) + + reconciler.omConnectionFactory = func(context *om.OMContext) om.Connection { + return om.NewEmptyMockedOmConnection(context) + } + + err = client.Create(context.TODO(), &opsManager) + assert.NoError(t, err) + + matchLabels, serviceName := appDBStatefulSetLabelsAndServiceName(opsManager.Name) + // app db sts should exist before monitoring is configured + appDbSts, err := statefulset.NewBuilder(). + SetName(opsManager.Spec.AppDB.Name()). + SetNamespace(opsManager.Namespace). + SetMatchLabels(matchLabels). + SetServiceName(serviceName). + AddVolumeClaimTemplates(appDBStatefulSetVolumeClaimtemplates()). + SetReplicas(startingMembers). + Build() + + assert.NoError(t, err) + err = client.CreateStatefulSet(appDbSts) + assert.NoError(t, err) + + res, err := reconciler.ReconcileAppDB(&opsManager, "i6ocEoHYJTteoNTX") + + assert.NoError(t, err) + assert.Equal(t, time.Duration(0), res.RequeueAfter) + assert.Equal(t, false, res.Requeue) + + // Scale the AppDB + opsManager.Spec.AppDB.Members = finalMembers + + if startingMembers < finalMembers { + for i := startingMembers; i < finalMembers-1; i++ { + err = client.Update(context.TODO(), &opsManager) + assert.NoError(t, err) + + res, err = reconciler.ReconcileAppDB(&opsManager, "i6ocEoHYJTteoNTX") + + assert.NoError(t, err) + assert.Equal(t, time.Duration(10000000000), res.RequeueAfter) + } + } else { + for i := startingMembers; i > finalMembers+1; i-- { + err = client.Update(context.TODO(), &opsManager) + assert.NoError(t, err) + + res, err = reconciler.ReconcileAppDB(&opsManager, "i6ocEoHYJTteoNTX") + + assert.NoError(t, err) + assert.Equal(t, time.Duration(10000000000), res.RequeueAfter) + } + } + + res, err = reconciler.ReconcileAppDB(&opsManager, "i6ocEoHYJTteoNTX") + assert.NoError(t, err) + assert.Equal(t, time.Duration(0), res.RequeueAfter) + + err = client.Get(context.TODO(), types.NamespacedName{Name: opsManager.Name, Namespace: opsManager.Namespace}, &opsManager) + assert.NoError(t, err) + + assert.Equal(t, finalMembers, opsManager.Status.AppDbStatus.Members) +} + +func buildAutomationConfigForAppDb(builder *omv1.OpsManagerBuilder, kubeManager *mock.MockedManager, acType agentType) (automationconfig.AutomationConfig, error) { + opsManager := builder.Build() + + // ensure the password exists for the Ops Manager User. The Ops Manager controller will have ensured this + createOpsManagerUserPasswordSecret(kubeManager.Client, opsManager, "my-password") + reconciler := newAppDbReconciler(kubeManager) + sts, err := construct.AppDbStatefulSet(opsManager, &env.PodEnvVars{ProjectID: "abcd"}, construct.AppDBStatefulSetOptions{}, nil) + if err != nil { + return automationconfig.AutomationConfig{}, err + } + return reconciler.buildAppDbAutomationConfig(opsManager, sts, acType, UnusedPrometheusConfiguration, zap.S()) + +} + +func checkDeploymentEqualToPublished(t *testing.T, expected automationconfig.AutomationConfig, s *corev1.Secret) { + actual, err := automationconfig.FromBytes(s.Data["cluster-config.json"]) + assert.NoError(t, err) + expectedBytes, err := json.Marshal(expected) + assert.NoError(t, err) + expectedAc := automationconfig.AutomationConfig{} + err = json.Unmarshal(expectedBytes, &expectedAc) + assert.NoError(t, err) + assert.Equal(t, expectedAc, actual) +} + +func newAppDbReconciler(mgr manager.Manager) *ReconcileAppDbReplicaSet { + return &ReconcileAppDbReplicaSet{ + ReconcileCommonController: newReconcileCommonController(mgr), + omConnectionFactory: om.NewEmptyMockedOmConnection, + versionMappingProvider: func(s string) ([]byte, error) { + return nil, nil + }, + } +} + +// createOpsManagerUserPasswordSecret creates the secret which holds the password that will be used for the Ops Manager user. +func createOpsManagerUserPasswordSecret(client *mock.MockedClient, om omv1.MongoDBOpsManager, password string) error { + return client.CreateSecret( + secret.Builder(). + SetName(om.Spec.AppDB.GetOpsManagerUserPasswordSecretName()). + SetNamespace(om.Namespace). + SetField("password", password). + Build(), + ) +} + +func readAutomationConfigSecret(t *testing.T, kubeManager *mock.MockedManager, opsManager omv1.MongoDBOpsManager) *corev1.Secret { + s := &corev1.Secret{} + key := kube.ObjectKey(opsManager.Namespace, opsManager.Spec.AppDB.AutomationConfigSecretName()) + assert.NoError(t, kubeManager.Client.Get(context.TODO(), key, s)) + return s +} + +func readAutomationConfigMonitoringSecret(t *testing.T, kubeManager *mock.MockedManager, opsManager omv1.MongoDBOpsManager) *corev1.Secret { + s := &corev1.Secret{} + key := kube.ObjectKey(opsManager.Namespace, opsManager.Spec.AppDB.MonitoringAutomationConfigSecretName()) + assert.NoError(t, kubeManager.Client.Get(context.TODO(), key, s)) + return s +} diff --git a/controllers/operator/authentication/authentication.go b/controllers/operator/authentication/authentication.go new file mode 100644 index 000000000..13df4bb9e --- /dev/null +++ b/controllers/operator/authentication/authentication.go @@ -0,0 +1,656 @@ +package authentication + +import ( + "crypto/sha1" + "crypto/sha256" + "fmt" + + "github.com/10gen/ops-manager-kubernetes/pkg/util/generate" + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" + "github.com/blang/semver" + "golang.org/x/xerrors" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/ldap" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "go.uber.org/zap" +) + +// AuthResource is an interface that a resources that can have authentication enabled should implement. +type AuthResource interface { + GetName() string + GetNamespace() string + GetSecurity() *mdbv1.Security + IsLDAPEnabled() bool + GetLDAP(password, caContents string) *ldap.Ldap + GetMinimumMajorVersion() uint64 +} + +// Options contains all the required values that are required to configure authentication +// for a set of processes +type Options struct { + // MinimumMajorVersion is required in order to determine if we will be enabling SCRAM-SHA-1 or SCRAM-SHA-256 + MinimumMajorVersion uint64 + // Mechanisms is a list of strings coming from MongoDB.Spec.Security.Authentication.Modes, these strings + // are mapped to the corresponding mechanisms in the Automation Config + Mechanisms []string + + // ProcessNames is a list of the names of the processes which authentication will be configured for + ProcessNames []string + + // AuthoritativeSet maps directly to auth.authoritativeSet + AuthoritativeSet bool + + // AgentMechanism indicates which Agent Mechanism should be configured. This should be in the Operator format. + // I.e. X509, SCRAM and not MONGODB-X509 or SCRAM-SHA-256 + AgentMechanism string + + // ClientCertificates configures whether or not Client Certificates are required or optional. + // If X509 is the only mechanism, they must be Required, otherwise they should be Optional + // so it is possible to use other auth mechanisms without needing to provide client certs. + ClientCertificates string + + CAFilePath string + + // Use Agent Client Auth + AgentsShouldUseClientAuthentication bool + + UserOptions + + // Ldap is the LDAP configuration that will be passed to the Automation Config. + // Only required if LDAP is configured as an authentication mechanism + Ldap *ldap.Ldap + + AutoUser string + + AutoPwd string + + AutoLdapGroupDN string +} + +func Redact(o Options) Options { + if o.Ldap != nil && o.Ldap.BindQueryPassword != "" { + ldapCopy := *o.Ldap + o.Ldap = &ldapCopy + o.Ldap.BindQueryPassword = "" + } + return o +} + +// UserOptions is a struct that contains the different user names +// of the agents that should be added to the automation config. +type UserOptions struct { + AutomationSubject string +} + +// Configure will configure all the specified authentication Mechanisms. We need to ensure we wait for +// the agents to reach ready state after each operation as prematurely updating the automation config can cause the agents to get stuck. +func Configure(conn om.Connection, opts Options, log *zap.SugaredLogger) error { + log.Infow("ensuring correct deployment mechanisms", "MinimumMajorVersion", opts.MinimumMajorVersion, "ProcessNames", opts.ProcessNames, "Mechanisms", opts.Mechanisms) + + if stringutil.Contains(opts.Mechanisms, util.X509) && !canEnableX509(conn) { + return xerrors.Errorf("unable to configure X509 with this version of Ops Manager, 4.0.11 is the minimum required version to enable X509") + } + + // we need to make sure the desired authentication mechanism for the agent exists. If the desired agent + // authentication mechanism does not exist in auth.deploymentAuthMechanisms, it is an invalid config + if err := ensureDeploymentsMechanismsExist(conn, opts, log); err != nil { + return xerrors.Errorf("error ensuring deployment mechanisms: %w", err) + } + + if err := om.WaitForReadyState(conn, opts.ProcessNames, log); err != nil { + return xerrors.Errorf("error waiting for ready state: %w", err) + } + + // we make sure that the AuthoritativeSet options in the AC is correct + if err := ensureAuthoritativeSetIsConfigured(conn, opts.AuthoritativeSet, log); err != nil { + return xerrors.Errorf("error ensuring that authoritative set is configured: %w", err) + } + + if err := om.WaitForReadyState(conn, opts.ProcessNames, log); err != nil { + return xerrors.Errorf("error waiting for ready state: %w", err) + } + + // once we have made sure that the deployment authentication mechanism array contains the desired auth mechanism + // we can then configure the agent authentication. + if err := enableAgentAuthentication(conn, opts, log); err != nil { + return xerrors.Errorf("error enabling agent authentication: %w", err) + } + + if err := om.WaitForReadyState(conn, opts.ProcessNames, log); err != nil { + return xerrors.Errorf("error waiting for ready state: %w", err) + } + + // once we have successfully enabled auth for the agents, we need to remove mechanisms we don't need. + // this ensures we don't have mechanisms enabled that have not been configured. + if err := removeUnusedAuthenticationMechanisms(conn, opts, log); err != nil { + return xerrors.Errorf("error removing unused authentication mechanisms %w", err) + } + + if err := om.WaitForReadyState(conn, opts.ProcessNames, log); err != nil { + return xerrors.Errorf("error waiting for ready state: %w", err) + } + + // we remove any unrequired deployment auth mechanisms. This will generally be mechanisms + // that we are disabling. + if err := removeUnrequiredDeploymentMechanisms(conn, opts, log); err != nil { + return xerrors.Errorf("error removing unrequired deployment mechanisms: %w", err) + } + + if err := om.WaitForReadyState(conn, opts.ProcessNames, log); err != nil { + return xerrors.Errorf("error waiting for ready state: %w", err) + } + + // Adding a client certificate for agents + if err := addOrRemoveAgentClientCertificate(conn, opts, log); err != nil { + return xerrors.Errorf("error adding client certificates for the agents: %w", err) + } + + if err := om.WaitForReadyState(conn, opts.ProcessNames, log); err != nil { + return xerrors.Errorf("error waiting for ready state: %w", err) + } + + // if scram if the specified authentication mechanism rotate passwrd + // remove this code("rotateAgentUserPassword" logic) when we remove support for OM version 4.4, and ask users to move to OM version 5.0.7+ + if err := rotateAgentUserPassword(conn, opts, log); err != nil { + return xerrors.Errorf("error rotating password for agent user: %w", err) + } + if err := om.WaitForReadyState(conn, opts.ProcessNames, log); err != nil { + return xerrors.Errorf("error waiting for ready state: %w", err) + } + + return nil +} + +// ConfigureScramCredentials creates both SCRAM-SHA-1 and SCRAM-SHA-256 credentials. This ensures +// that changes to the authentication settings on the MongoDB resources won't leave MongoDBUsers without +// the correct credentials. +func ConfigureScramCredentials(user *om.MongoDBUser, password string) error { + + scram256Salt, err := GenerateSalt(sha256.New) + if err != nil { + return xerrors.Errorf("error generating scramSha256 salt: %w", err) + } + + scram1Salt, err := GenerateSalt(sha1.New) + if err != nil { + return xerrors.Errorf("error generating scramSha1 salt: %w", err) + } + + scram256Creds, err := ComputeScramShaCreds(user.Username, password, scram256Salt, ScramSha256) + if err != nil { + return xerrors.Errorf("error generating scramSha256 creds: %w", err) + } + scram1Creds, err := ComputeScramShaCreds(user.Username, password, scram1Salt, MongoDBCR) + if err != nil { + return xerrors.Errorf("error generating scramSha1Creds: %w", err) + } + user.ScramSha256Creds = scram256Creds + user.ScramSha1Creds = scram1Creds + return nil +} + +// Disable disables all authentication mechanisms, and waits for the agents to reach goal state. It is still required to provide +// automation agent user name, password and keyfile contents to ensure a valid Automation Config. +func Disable(conn om.Connection, opts Options, deleteUsers bool, log *zap.SugaredLogger) error { + ac, err := conn.ReadAutomationConfig() + if err != nil { + return xerrors.Errorf("error reading automation config: %w", err) + } + + // Disabling auth must be done in two steps, otherwise the agents might not be able to transition. + // From a slack conversation with Agent team: + // "First disable with leaving credentials and mechanisms and users in place. Wait for goal state. Then remove the rest" + // "assume the agent is stateless. So if you remove the authentication information before it has transitioned then it won't be able to transition" + if ac.Auth.IsEnabled() { + log.Info("Disabling authentication") + + err := conn.ReadUpdateAutomationConfig(func(ac *om.AutomationConfig) error { + ac.Auth.Disabled = true + return nil + }, log) + + if err != nil { + return xerrors.Errorf("error read/updating automation config: %w", err) + } + + if err := om.WaitForReadyState(conn, opts.ProcessNames, log); err != nil { + return xerrors.Errorf("error waiting for ready state: %w", err) + } + + } + + err = conn.ReadUpdateAutomationConfig(func(ac *om.AutomationConfig) error { + if err := ac.EnsureKeyFileContents(); err != nil { + return xerrors.Errorf("error ensuring keyfile contents: %w", err) + } + if _, err := ac.EnsurePassword(); err != nil { + return xerrors.Errorf("error ensuring agent password: %w", err) + } + + // we don't always want to delete the users. This can result in the agents getting stuck + // certain situations around auth transitions. + if deleteUsers { + ac.Auth.Users = []*om.MongoDBUser{} + } + ac.Auth.AutoAuthMechanisms = []string{} + ac.Auth.DeploymentAuthMechanisms = []string{} + ac.Auth.AutoUser = util.AutomationAgentName + ac.Auth.KeyFile = util.AutomationAgentKeyFilePathInContainer + ac.Auth.KeyFileWindows = util.AutomationAgentWindowsKeyFilePath + ac.Auth.AuthoritativeSet = opts.AuthoritativeSet + ac.AgentSSL.ClientCertificateMode = util.OptionalClientCertficates + ac.AgentSSL.AutoPEMKeyFilePath = util.MergoDelete + return nil + }, log) + + if err != nil { + return xerrors.Errorf("error read/updating automation config: %w", err) + } + + // It is only required to update monitoring and backup agent configs in a 3 agent environment. + // we should eventually be able to remove this. + err = conn.ReadUpdateMonitoringAgentConfig(func(config *om.MonitoringAgentConfig) error { + config.DisableX509Authentication() + return nil + }, log) + + if err != nil { + return xerrors.Errorf("error read/updating monitoring config: %w", err) + } + + err = conn.ReadUpdateBackupAgentConfig(func(config *om.BackupAgentConfig) error { + config.DisableX509Authentication() + return nil + }, log) + + if err != nil { + return xerrors.Errorf("error read/updating backup agent config: %w", err) + } + + if err := om.WaitForReadyState(conn, opts.ProcessNames, log); err != nil { + return xerrors.Errorf("error waiting for ready state: %w", err) + } + + if err := om.WaitForReadyState(conn, opts.ProcessNames, log); err != nil { + return xerrors.Errorf("error waiting for ready state: %w", err) + } + return nil +} + +func getMechanismName(mongodbResourceMode string, ac *om.AutomationConfig, minimumMajorVersion uint64) MechanismName { + switch mongodbResourceMode { + case util.X509: + return MongoDBX509 + case util.LDAP: + return LDAPPlain + case util.SCRAMSHA1: + return ScramSha1 + case util.MONGODBCR: + return MongoDBCR + case util.SCRAMSHA256: + return ScramSha256 + case util.SCRAM: + // if we have already configured authentication and it has been set to MONGODB-CR/SCRAM-SHA-1 + // we can not transition. This needs to be done in the UI + + // if no authentication has been configured, the default value for "AutoAuthMechanism" is "MONGODB-CR" + // even if authentication is disabled, so we need to ensure that auth has been enabled. + if ac.Auth.AutoAuthMechanism == string(MongoDBCR) && ac.Auth.IsEnabled() { + return MongoDBCR + } + + if minimumMajorVersion < 4 { + return MongoDBCR + } else { + return ScramSha256 + } + } + // this should never be reached as validation of this string happens at the CR level + panic(fmt.Sprintf("unknown mechanism name %s", mongodbResourceMode)) +} + +// mechanism is an interface that needs to be implemented for any Ops Manager authentication mechanism +type Mechanism interface { + EnableAgentAuthentication(opts Options, log *zap.SugaredLogger) error + DisableAgentAuthentication(log *zap.SugaredLogger) error + EnableDeploymentAuthentication(opts Options) error + DisableDeploymentAuthentication() error + // IsAgentAuthenticationConfigured should not rely on util.MergoDelete since the method is always + // called directly after deserializing the response from OM which should not contain the util.MergoDelete value in any field. + IsAgentAuthenticationConfigured() bool + IsDeploymentAuthenticationConfigured() bool +} + +var _ Mechanism = ConnectionScramSha{} +var _ Mechanism = AutomationConfigScramSha{} +var _ Mechanism = ConnectionX509{} + +// removeUnusedAuthenticationMechanisms removes authentication mechanism that were previously enabled, or were required +// as part of the transition process. +func removeUnusedAuthenticationMechanisms(conn om.Connection, opts Options, log *zap.SugaredLogger) error { + ac, err := conn.ReadAutomationConfig() + if err != nil { + return xerrors.Errorf("error reading automation config: %w", err) + } + + automationConfigAuthMechanismNames := getMechanismNames(ac, opts.MinimumMajorVersion, opts.Mechanisms) + + unrequiredMechanisms := mechanismsToDisable(automationConfigAuthMechanismNames) + + log.Infow("configuring agent authentication mechanisms", "enabled", opts.AgentMechanism, "disabling", unrequiredMechanisms) + for _, mn := range unrequiredMechanisms { + m := fromName(mn, ac, conn, opts) + if m.IsAgentAuthenticationConfigured() { + log.Infof("disabling authentication mechanism %s", mn) + if err := m.DisableAgentAuthentication(log); err != nil { + return xerrors.Errorf("error disabling agent authentication: %w", err) + } + } else { + log.Infof("mechanism %s is already disabled", mn) + } + } + return nil +} + +// enableAgentAuthentication determines which agent authentication mechanism should be configured +// and enables it in Ops Manager +func enableAgentAuthentication(conn om.Connection, opts Options, log *zap.SugaredLogger) error { + + ac, err := conn.ReadAutomationConfig() + if err != nil { + return xerrors.Errorf("error reading automation config: %w", err) + } + + // we then configure the agent authentication for that type + agentAuthMechanism := getMechanismName(opts.AgentMechanism, ac, opts.MinimumMajorVersion) + if err := ensureAgentAuthenticationIsConfigured(conn, opts, agentAuthMechanism, log); err != nil { + return xerrors.Errorf("error ensuring agent authentication is configured: %w", err) + } + + return nil +} + +// ensureAuthoritativeSetIsConfigured makes sure that the authoritativeSet options is correctly configured +// in Ops Manager +func ensureAuthoritativeSetIsConfigured(conn om.Connection, authoritativeSet bool, log *zap.SugaredLogger) error { + ac, err := conn.ReadAutomationConfig() + + if err != nil { + return xerrors.Errorf("error reading automation config: %w", err) + } + + if ac.Auth.AuthoritativeSet == authoritativeSet { + log.Debugf("Authoritative set %t is already configured", authoritativeSet) + return nil + } + + return conn.ReadUpdateAutomationConfig(func(ac *om.AutomationConfig) error { + ac.Auth.AuthoritativeSet = authoritativeSet + return nil + }, log) + +} + +// ensureDeploymentsMechanismsExist makes sure that the corresponding deployment mechanisms which are required +// in order to enable the desired agent auth mechanisms are configured. +func ensureDeploymentsMechanismsExist(conn om.Connection, opts Options, log *zap.SugaredLogger) error { + ac, err := conn.ReadAutomationConfig() + if err != nil { + return xerrors.Errorf("error reading automation config: %w", err) + } + + // "opts.Mechanisms" is the list of mechanism names passed through from the MongoDB resource. + // We need to convert this to the list of strings the automation config expects. + automationConfigMechanismNames := getMechanismNames(ac, opts.MinimumMajorVersion, opts.Mechanisms) + + log.Debugf("Automation config authentication mechanisms: %+v", automationConfigMechanismNames) + if err := ensureDeploymentMechanisms(conn, ac, automationConfigMechanismNames, opts, log); err != nil { + return xerrors.Errorf("error ensuring deployment mechanisms: %w", err) + } + + return nil +} + +// removeUnrequiredDeploymentMechanisms updates the given AutomationConfig struct to enable all the given +// authentication mechanisms. +func removeUnrequiredDeploymentMechanisms(conn om.Connection, opts Options, log *zap.SugaredLogger) error { + ac, err := conn.ReadAutomationConfig() + if err != nil { + return xerrors.Errorf("error reading automation config: %w", err) + } + + // "opts.Mechanisms" is the list of mechanism names passed through from the MongoDB resource. + // We need to convert this to the list of strings the automation config expects. + automationConfigAuthMechanismNames := getMechanismNames(ac, opts.MinimumMajorVersion, opts.Mechanisms) + + toDisable := mechanismsToDisable(automationConfigAuthMechanismNames) + log.Infow("Removing unrequired deployment authentication mechanisms", "Mechanisms", toDisable) + if err := ensureDeploymentMechanismsAreDisabled(conn, toDisable, opts, log); err != nil { + return xerrors.Errorf("error ensuring deployment mechanisms are disabled: %w", err) + } + + return nil +} + +// addOrRemoveAgentClientCertificate changes the automation config so it enables or disables +// client TLS authentication. +// This function will not change the automation config if x509 agent authentication has been +// enabled already (by the x509 auth package). +func addOrRemoveAgentClientCertificate(conn om.Connection, opts Options, log *zap.SugaredLogger) error { + // If x509 is not enabled but still Client Certificates are, this automation config update + // will add the required configuration. + return conn.ReadUpdateAutomationConfig(func(ac *om.AutomationConfig) error { + if getMechanismName(opts.AgentMechanism, ac, opts.MinimumMajorVersion) == MongoDBX509 { + // If TLS client authentication is managed by x509, we won't disable or enable it + // in here. + return nil + } + + if opts.AgentsShouldUseClientAuthentication { + ac.AgentSSL = &om.AgentSSL{ + AutoPEMKeyFilePath: util.AutomationAgentPemFilePath, + CAFilePath: opts.CAFilePath, + ClientCertificateMode: opts.ClientCertificates, + } + } else { + ac.AgentSSL = &om.AgentSSL{ + AutoPEMKeyFilePath: util.MergoDelete, + ClientCertificateMode: util.OptionalClientCertficates, + } + } + return nil + }, log) +} + +func AreBackupAndMonitoringAgentPresent(users []*om.MongoDBUser) bool { + count := 0 + for _, user := range users { + if user.Username == "mms-backup-agent" { + count += 1 + } + if user.Username == "mms-monitoring-agent" { + count += 1 + } + } + return count == 2 +} + +func rotateAgentUserPassword(conn om.Connection, opts Options, log *zap.SugaredLogger) error { + return conn.ReadUpdateAutomationConfig(func(ac *om.AutomationConfig) error { + if conn.OpsManagerVersion().IsCloudManager() { + return nil + } + + omVersion, err := conn.OpsManagerVersion().Semver() + if err != nil { + log.Debugw("Failed to fetch OpsManager version: %s", err) + return nil + } + // This bug has been fixed in OpsManager version 5.0.7 and there is no need to rotate agent password: + // https://www.mongodb.com/docs/ops-manager/current/release-notes/application/#onprem-server-5-0-7 + if omVersion.GTE(semver.MustParse("5.0.7")) { + return nil + } + // check if nonitoring and backup agent users are already present + if AreBackupAndMonitoringAgentPresent(ac.Auth.Users) { + log.Debug("Skipping rotating agent password since monitoring and backup agent is present.") + return nil + } + + log.Debug("Configuring agent password rotation.") + if getMechanismName(opts.AgentMechanism, ac, opts.MinimumMajorVersion) == ScramSha256 { + ac.Auth.NewAutoPwd = generate.GenerateRandomPassword() + } + return nil + }, log) +} + +func getMechanismNames(ac *om.AutomationConfig, minimumMajorVersion uint64, mechanisms []string) []MechanismName { + automationConfigMechanismNames := make([]MechanismName, 0) + for _, m := range mechanisms { + automationConfigMechanismNames = append(automationConfigMechanismNames, getMechanismName(m, ac, minimumMajorVersion)) + } + return automationConfigMechanismNames +} + +// MechanismName corresponds to the string used in the automation config representing +// a particular type of authentication +type MechanismName string + +const ( + ScramSha256 MechanismName = "SCRAM-SHA-256" + ScramSha1 MechanismName = "SCRAM-SHA-1" + MongoDBX509 MechanismName = "MONGODB-X509" + LDAPPlain MechanismName = "PLAIN" + + // MONGODB-CR is an umbrella term for SCRAM-SHA-1 and MONGODB-CR for legacy reasons, once MONGODB-CR + // is enabled, users can auth with SCRAM-SHA-1 credentials + MongoDBCR MechanismName = "MONGODB-CR" + + // Sentinel value indicating auth is being disabled, this is exclusive to the Operator + DisableAuth MechanismName = "DISABLE-AUTH" +) + +// supportedMechanisms returns a list of all the authentication mechanisms +// that can be configured by the Operator +func supportedMechanisms() []MechanismName { + return []MechanismName{ScramSha256, MongoDBCR, MongoDBX509, LDAPPlain} +} + +// fromName returns an implementation of mechanism from the string value +// used in the AutomationConfig. All supported fields are in supportedMechanisms +func fromName(name MechanismName, ac *om.AutomationConfig, conn om.Connection, opts Options) Mechanism { + switch name { + case MongoDBCR: + return NewConnectionCR(conn, ac) + case ScramSha1: + return NewConnectionScramSha1(conn, ac) + case ScramSha256: + return NewConnectionScramSha256(conn, ac) + case MongoDBX509: + return NewConnectionX509(conn, ac, opts) + case LDAPPlain: + return NewLdap(conn, ac, opts) + } + panic(xerrors.Errorf("unknown authentication mechanism %s. Supported mechanisms are %+v", name, supportedMechanisms())) +} + +// mechanismsToDisable returns a list of mechanisms which need to be disabled +// based on the currently supported authentication mechanisms and the desiredMechanisms +func mechanismsToDisable(desiredMechanisms []MechanismName) []MechanismName { + toDisable := make([]MechanismName, 0) + for _, m := range supportedMechanisms() { + if !containsMechanismName(desiredMechanisms, m) { + toDisable = append(toDisable, m) + } + } + return toDisable +} + +// ensureAgentAuthenticationIsConfigured will configure the agent authentication settings based on the desiredAgentAuthMechanism +func ensureAgentAuthenticationIsConfigured(conn om.Connection, opts Options, desiredAgentAuthMechanismName MechanismName, log *zap.SugaredLogger) error { + ac, err := conn.ReadAutomationConfig() + if err != nil { + return xerrors.Errorf("error reading automation config: %w", err) + } + + m := fromName(desiredAgentAuthMechanismName, ac, conn, opts) + if m.IsAgentAuthenticationConfigured() { + log.Infof("Agent authentication mechanism %s is already configured", desiredAgentAuthMechanismName) + return nil + } + + log.Infof("Enabling %s agent authentication", desiredAgentAuthMechanismName) + return m.EnableAgentAuthentication(opts, log) +} + +// ensureDeploymentMechanisms configures the given AutomationConfig to allow deployments to +// authenticate using the specified mechanisms +func ensureDeploymentMechanisms(conn om.Connection, ac *om.AutomationConfig, desiredDeploymentAuthMechanisms []MechanismName, opts Options, log *zap.SugaredLogger) error { + allRequiredDeploymentMechanismsAreConfigured := true + for _, mn := range desiredDeploymentAuthMechanisms { + if !fromName(mn, ac, conn, opts).IsDeploymentAuthenticationConfigured() { + allRequiredDeploymentMechanismsAreConfigured = false + } else { + log.Debugf("Deployment mechanism %s is already configured", mn) + } + } + + if allRequiredDeploymentMechanismsAreConfigured { + log.Info("All required deployment authentication mechanisms are configured") + return nil + } + + return conn.ReadUpdateAutomationConfig(func(ac *om.AutomationConfig) error { + for _, mechanismName := range desiredDeploymentAuthMechanisms { + log.Debugf("Enabling deployment mechanism %s", mechanismName) + if err := fromName(mechanismName, ac, conn, opts).EnableDeploymentAuthentication(opts); err != nil { + return xerrors.Errorf("error enabling deployment authentication: %w", err) + } + } + return nil + }, log) +} + +// ensureDeploymentMechanismsAreDisabled configures the given AutomationConfig to allow deployments to +// authenticate using the specified mechanisms +func ensureDeploymentMechanismsAreDisabled(conn om.Connection, mechanismsToDisable []MechanismName, opts Options, log *zap.SugaredLogger) error { + ac, err := conn.ReadAutomationConfig() + if err != nil { + return xerrors.Errorf("error reading automation config: %w", err) + } + + allDeploymentMechanismsAreDisabled := true + for _, mn := range mechanismsToDisable { + if fromName(mn, ac, conn, opts).IsDeploymentAuthenticationConfigured() { + allDeploymentMechanismsAreDisabled = false + } + } + + if allDeploymentMechanismsAreDisabled { + log.Infof("Mechanisms %+v are all already disabled", mechanismsToDisable) + return nil + } + return conn.ReadUpdateAutomationConfig(func(ac *om.AutomationConfig) error { + for _, mechanismName := range mechanismsToDisable { + log.Debugf("disabling deployment mechanism %s", mechanismName) + if err := fromName(mechanismName, ac, conn, opts).DisableDeploymentAuthentication(); err != nil { + return xerrors.Errorf("error disabling deployment authentication: %w", err) + } + } + return nil + }, log) +} + +// containsMechanismName returns true if there is at least one MechanismName in `slice` +// that is equal to `mn`. +func containsMechanismName(slice []MechanismName, mn MechanismName) bool { + for _, item := range slice { + if item == mn { + return true + } + } + return false +} diff --git a/controllers/operator/authentication/configure_authentication_test.go b/controllers/operator/authentication/configure_authentication_test.go new file mode 100644 index 000000000..c4378f835 --- /dev/null +++ b/controllers/operator/authentication/configure_authentication_test.go @@ -0,0 +1,369 @@ +package authentication + +import ( + "testing" + + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/stretchr/testify/assert" + "go.uber.org/zap" +) + +func init() { + logger, _ := zap.NewDevelopment() + zap.ReplaceGlobals(logger) +} + +func TestConfigureScramSha1FallbackToCr(t *testing.T) { + dep := om.NewDeployment() + conn := om.NewMockedOmConnection(dep) + + opts := Options{ + MinimumMajorVersion: 3, + AuthoritativeSet: true, + ProcessNames: []string{"process-1", "process-2", "process-3"}, + Mechanisms: []string{"SCRAM"}, + AgentMechanism: "SCRAM", + } + + if err := Configure(conn, opts, zap.S()); err != nil { + t.Fatal(err) + } + + ac, err := conn.ReadAutomationConfig() + + if err != nil { + t.Fatal(err) + } + + assertAuthenticationEnabled(t, ac.Auth) + assertAuthenticationMechanism(t, ac.Auth, "MONGODB-CR") + +} + +func TestConfigureScramSha256(t *testing.T) { + + dep := om.NewDeployment() + conn := om.NewMockedOmConnection(dep) + + opts := Options{ + MinimumMajorVersion: 4, + AuthoritativeSet: true, + ProcessNames: []string{"process-1", "process-2", "process-3"}, + Mechanisms: []string{"SCRAM"}, + AgentMechanism: "SCRAM", + } + + if err := Configure(conn, opts, zap.S()); err != nil { + t.Fatal(err) + } + + ac, err := conn.ReadAutomationConfig() + + if err != nil { + t.Fatal(err) + } + + assertAuthenticationEnabled(t, ac.Auth) + assertAuthenticationMechanism(t, ac.Auth, "SCRAM-SHA-256") +} + +func TestConfigureX509(t *testing.T) { + + dep := om.NewDeployment() + conn := om.NewMockedOmConnection(dep) + + opts := Options{ + MinimumMajorVersion: 4, + AuthoritativeSet: true, + ProcessNames: []string{"process-1", "process-2", "process-3"}, + Mechanisms: []string{"X509"}, + AgentMechanism: "X509", + ClientCertificates: util.RequireClientCertificates, + UserOptions: UserOptions{ + AutomationSubject: validSubject("automation"), + }, + } + + if err := Configure(conn, opts, zap.S()); err != nil { + t.Fatal(err) + } + + ac, err := conn.ReadAutomationConfig() + + if err != nil { + t.Fatal(err) + } + + assertAuthenticationEnabled(t, ac.Auth) + assertAuthenticationMechanism(t, ac.Auth, "MONGODB-X509") +} + +func TestConfigureScramSha1(t *testing.T) { + dep := om.NewDeployment() + conn := om.NewMockedOmConnection(dep) + + opts := Options{ + MinimumMajorVersion: 4, + AuthoritativeSet: true, + ProcessNames: []string{"process-1", "process-2", "process-3"}, + Mechanisms: []string{"SCRAM-SHA-1"}, + AgentMechanism: "SCRAM-SHA-1", + } + + if err := Configure(conn, opts, zap.S()); err != nil { + t.Fatal(err) + } + + ac, err := conn.ReadAutomationConfig() + assert.NoError(t, err) + + assertAuthenticationEnabled(t, ac.Auth) + assertAuthenticationMechanism(t, ac.Auth, "SCRAM-SHA-1") +} + +func TestConfigureMultipleAuthenticationMechanisms(t *testing.T) { + + dep := om.NewDeployment() + conn := om.NewMockedOmConnection(dep) + + opts := Options{ + MinimumMajorVersion: 4, + AuthoritativeSet: true, + ProcessNames: []string{"process-1", "process-2", "process-3"}, + Mechanisms: []string{"X509", "SCRAM"}, + AgentMechanism: "SCRAM", + UserOptions: UserOptions{ + AutomationSubject: validSubject("automation"), + }, + } + + if err := Configure(conn, opts, zap.S()); err != nil { + t.Fatal(err) + } + + ac, err := conn.ReadAutomationConfig() + + if err != nil { + t.Fatal(err) + } + + assertAuthenticationEnabled(t, ac.Auth) + + assert.Contains(t, ac.Auth.AutoAuthMechanisms, "SCRAM-SHA-256") + + assert.Len(t, ac.Auth.DeploymentAuthMechanisms, 2) + assert.Len(t, ac.Auth.AutoAuthMechanisms, 1) + assert.Contains(t, ac.Auth.DeploymentAuthMechanisms, "SCRAM-SHA-256") + assert.Contains(t, ac.Auth.DeploymentAuthMechanisms, "MONGODB-X509") +} + +func TestScramSha1MongoDBUpgrade(t *testing.T) { + + dep := om.NewDeployment() + conn := om.NewMockedOmConnection(dep) + + opts := Options{ + MinimumMajorVersion: 3, + AuthoritativeSet: true, + ProcessNames: []string{"process-1", "process-2", "process-3"}, + Mechanisms: []string{"SCRAM"}, + AgentMechanism: "SCRAM", + } + + if err := Configure(conn, opts, zap.S()); err != nil { + t.Fatal(err) + } + + ac, err := conn.ReadAutomationConfig() + + if err != nil { + t.Fatal(err) + } + + assertAuthenticationEnabled(t, ac.Auth) + assertAuthenticationMechanism(t, ac.Auth, "MONGODB-CR") + + opts = Options{ + MinimumMajorVersion: 4, + AuthoritativeSet: true, + ProcessNames: []string{"process-1", "process-2", "process-3"}, + Mechanisms: []string{"SCRAM"}, + AgentMechanism: "SCRAM", + } + + if err := Configure(conn, opts, zap.S()); err != nil { + t.Fatal(err) + } + + ac, err = conn.ReadAutomationConfig() + + if err != nil { + t.Fatal(err) + } + + assertAuthenticationEnabled(t, ac.Auth) + assertAuthenticationMechanism(t, ac.Auth, "MONGODB-CR") +} + +func TestConfigureAndDisable(t *testing.T) { + dep := om.NewDeployment() + conn := om.NewMockedOmConnection(dep) + + opts := Options{ + MinimumMajorVersion: 3, + AuthoritativeSet: true, + ProcessNames: []string{"process-1", "process-2", "process-3"}, + Mechanisms: []string{"SCRAM"}, + AgentMechanism: "SCRAM", + UserOptions: UserOptions{ + AutomationSubject: validSubject("automation"), + }, + } + + if err := Configure(conn, opts, zap.S()); err != nil { + t.Fatal(err) + } + + ac, err := conn.ReadAutomationConfig() + + if err != nil { + t.Fatal(err) + } + + assertAuthenticationEnabled(t, ac.Auth) + assertAuthenticationMechanism(t, ac.Auth, "MONGODB-CR") + + if err := Disable(conn, opts, true, zap.S()); err != nil { + t.Fatal(err) + } + + ac, err = conn.ReadAutomationConfig() + if err != nil { + t.Fatal(err) + } + + assertAuthenticationDisabled(t, ac.Auth) + +} + +func TestDisableAuthentication(t *testing.T) { + dep := om.NewDeployment() + conn := om.NewMockedOmConnection(dep) + + // enable authentication + _ = conn.ReadUpdateAutomationConfig(func(ac *om.AutomationConfig) error { + ac.Auth.Enable() + return nil + }, zap.S()) + + if err := Disable(conn, Options{}, true, zap.S()); err != nil { + t.Fatal(err) + } + + ac, err := conn.ReadAutomationConfig() + if err != nil { + t.Fatal(err) + } + + assertAuthenticationDisabled(t, ac.Auth) +} + +func TestGetCorrectAuthMechanismFromVersion(t *testing.T) { + + conn := om.NewMockedOmConnection(om.NewDeployment()) + ac, _ := conn.ReadAutomationConfig() + + mechanismNames := getMechanismNames(ac, 3, []string{"X509"}) + + assert.Len(t, mechanismNames, 1) + assert.Contains(t, mechanismNames, MechanismName("MONGODB-X509")) + + mechanismNames = getMechanismNames(ac, 3, []string{"SCRAM", "X509"}) + + assert.Contains(t, mechanismNames, MechanismName("MONGODB-CR")) + assert.Contains(t, mechanismNames, MechanismName("MONGODB-X509")) + + mechanismNames = getMechanismNames(ac, 4, []string{"SCRAM", "X509"}) + + assert.Contains(t, mechanismNames, MechanismName("SCRAM-SHA-256")) + assert.Contains(t, mechanismNames, MechanismName("MONGODB-X509")) + + // enable MONGODB-CR + ac.Auth.AutoAuthMechanism = "MONGODB-CR" + ac.Auth.Enable() + + mechanismNames = getMechanismNames(ac, 4, []string{"SCRAM", "X509"}) + + assert.Contains(t, mechanismNames, MechanismName("MONGODB-CR")) + assert.Contains(t, mechanismNames, MechanismName("MONGODB-X509")) +} + +func assertAuthenticationEnabled(t *testing.T, auth *om.Auth) { + assertAuthenticationEnabledWithUsers(t, auth, 0) +} + +func assertAuthenticationEnabledWithUsers(t *testing.T, auth *om.Auth, numUsers int) { + assert.True(t, auth.AuthoritativeSet) + assert.False(t, auth.Disabled) + assert.NotEmpty(t, auth.Key) + assert.NotEmpty(t, auth.KeyFileWindows) + assert.NotEmpty(t, auth.KeyFile) + assert.Len(t, auth.Users, numUsers) + assert.True(t, noneNil(auth.Users)) +} + +func assertAuthenticationDisabled(t *testing.T, auth *om.Auth) { + assert.True(t, auth.Disabled) + assert.Empty(t, auth.DeploymentAuthMechanisms) + assert.Empty(t, auth.AutoAuthMechanisms) + assert.Equal(t, auth.AutoUser, util.AutomationAgentName) + assert.NotEmpty(t, auth.Key) + assert.NotEmpty(t, auth.AutoPwd) + assert.True(t, len(auth.Users) == 0 || allNil(auth.Users)) +} + +func assertAuthenticationMechanism(t *testing.T, auth *om.Auth, mechanism string) { + assert.Len(t, auth.DeploymentAuthMechanisms, 1) + assert.Len(t, auth.AutoAuthMechanisms, 1) + assert.Len(t, auth.Users, 0) + assert.Contains(t, auth.DeploymentAuthMechanisms, mechanism) + assert.Contains(t, auth.AutoAuthMechanisms, mechanism) +} + +func assertDeploymentMechanismsConfigured(t *testing.T, authMechanism Mechanism) { + _ = authMechanism.EnableDeploymentAuthentication(Options{CAFilePath: util.CAFilePathInContainer}) + assert.True(t, authMechanism.IsDeploymentAuthenticationConfigured()) +} + +func assertAgentAuthenticationDisabled(t *testing.T, authMechanism Mechanism, opts Options) { + _ = authMechanism.EnableAgentAuthentication(opts, zap.S()) + assert.True(t, authMechanism.IsAgentAuthenticationConfigured()) + + _ = authMechanism.DisableAgentAuthentication(zap.S()) + assert.False(t, authMechanism.IsAgentAuthenticationConfigured()) +} + +func noneNil(users []*om.MongoDBUser) bool { + for i := range users { + if users[i] == nil { + return false + } + } + return true +} + +func allNil(users []*om.MongoDBUser) bool { + for i := range users { + if users[i] != nil { + return false + } + } + return true +} + +func createConnectionAndAutomationConfig() (om.Connection, *om.AutomationConfig) { + conn := om.NewMockedOmConnection(om.NewDeployment()) + ac, _ := conn.ReadAutomationConfig() + return conn, ac +} diff --git a/controllers/operator/authentication/ldap.go b/controllers/operator/authentication/ldap.go new file mode 100644 index 000000000..84eaa02b9 --- /dev/null +++ b/controllers/operator/authentication/ldap.go @@ -0,0 +1,132 @@ +package authentication + +import ( + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" + "go.uber.org/zap" +) + +type ldapAuthMechanism struct { + AutomationConfig *om.AutomationConfig + Conn om.Connection + Options Options +} + +func NewLdap(conn om.Connection, ac *om.AutomationConfig, opts Options) Mechanism { + return &ldapAuthMechanism{ + AutomationConfig: ac, + Conn: conn, + Options: opts, + } +} + +func (l *ldapAuthMechanism) EnableAgentAuthentication(opts Options, log *zap.SugaredLogger) error { + log.Info("Configuring LDAP authentication") + err := l.Conn.ReadUpdateAutomationConfig(func(ac *om.AutomationConfig) error { + if err := ac.EnsureKeyFileContents(); err != nil { + return err + } + auth := ac.Auth + auth.AutoPwd = opts.AutoPwd + auth.Disabled = false + auth.AuthoritativeSet = opts.AuthoritativeSet + auth.KeyFile = util.AutomationAgentKeyFilePathInContainer + auth.KeyFileWindows = util.AutomationAgentWindowsKeyFilePath + + auth.AutoUser = l.Options.AutomationSubject + auth.LdapGroupDN = opts.AutoLdapGroupDN + auth.AutoAuthMechanisms = []string{string(LDAPPlain)} + return nil + }, log) + + if err != nil { + return err + } + + log.Info("Configuring backup agent user") + err = l.Conn.ReadUpdateBackupAgentConfig(func(config *om.BackupAgentConfig) error { + config.EnableLdapAuthentication(l.Options.AutomationSubject, opts.AutoPwd) + config.SetLdapGroupDN(opts.AutoLdapGroupDN) + return nil + }, log) + + if err != nil { + return err + } + + log.Info("Configuring monitoring agent user") + return l.Conn.ReadUpdateMonitoringAgentConfig(func(config *om.MonitoringAgentConfig) error { + config.EnableLdapAuthentication(l.Options.AutomationSubject, opts.AutoPwd) + config.SetLdapGroupDN(opts.AutoLdapGroupDN) + return nil + }, log) +} + +func (l *ldapAuthMechanism) DisableAgentAuthentication(log *zap.SugaredLogger) error { + err := l.Conn.ReadUpdateAutomationConfig(func(ac *om.AutomationConfig) error { + + if stringutil.Contains(ac.Auth.AutoAuthMechanisms, string(LDAPPlain)) { + ac.Auth.AutoAuthMechanisms = stringutil.Remove(ac.Auth.AutoAuthMechanisms, string(LDAPPlain)) + } + return nil + + }, log) + + if err != nil { + return err + } + + err = l.Conn.ReadUpdateMonitoringAgentConfig(func(config *om.MonitoringAgentConfig) error { + config.DisableLdapAuthentication() + return nil + }, log) + + if err != nil { + return err + } + + return l.Conn.ReadUpdateBackupAgentConfig(func(config *om.BackupAgentConfig) error { + config.DisableLdapAuthentication() + return nil + }, log) +} + +func (l *ldapAuthMechanism) EnableDeploymentAuthentication(opts Options) error { + ac := l.AutomationConfig + ac.Ldap = opts.Ldap + if !stringutil.Contains(ac.Auth.DeploymentAuthMechanisms, string(LDAPPlain)) { + ac.Auth.DeploymentAuthMechanisms = append(ac.Auth.DeploymentAuthMechanisms, string(LDAPPlain)) + } + + return nil +} + +func (l *ldapAuthMechanism) DisableDeploymentAuthentication() error { + ac := l.AutomationConfig + ac.Ldap = nil + ac.Auth.DeploymentAuthMechanisms = stringutil.Remove(ac.Auth.DeploymentAuthMechanisms, string(LDAPPlain)) + return nil +} + +func (l *ldapAuthMechanism) IsAgentAuthenticationConfigured() bool { + ac := l.AutomationConfig + if ac.Auth.Disabled { + return false + } + + if !stringutil.Contains(ac.Auth.AutoAuthMechanisms, string(LDAPPlain)) { + return false + } + + if ac.Auth.AutoUser == "" || ac.Auth.AutoPwd == "" { + return false + } + + return true +} + +func (l *ldapAuthMechanism) IsDeploymentAuthenticationConfigured() bool { + ac := l.AutomationConfig + return stringutil.Contains(ac.Auth.DeploymentAuthMechanisms, string(LDAPPlain)) && ac.Ldap != nil && *ac.Ldap == *l.Options.Ldap +} diff --git a/controllers/operator/authentication/ldap_test.go b/controllers/operator/authentication/ldap_test.go new file mode 100644 index 000000000..e4244316c --- /dev/null +++ b/controllers/operator/authentication/ldap_test.go @@ -0,0 +1,81 @@ +package authentication + +import ( + "testing" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/ldap" + "go.uber.org/zap" + + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/stretchr/testify/assert" +) + +func TestLdapDeploymentMechanism(t *testing.T) { + conn := om.NewMockedOmConnection(om.NewDeployment()) + ac, _ := conn.ReadAutomationConfig() + opts := Options{ + Ldap: &ldap.Ldap{ + BindMethod: "BindMethod", + BindQueryUser: "BindQueryUser", + Servers: "Servers", + }, + } + l := NewLdap(conn, ac, opts) + + if err := l.EnableDeploymentAuthentication(opts); err != nil { + t.Fatal(err) + } + assert.Contains(t, ac.Auth.DeploymentAuthMechanisms, string(LDAPPlain)) + assert.Equal(t, "BindQueryUser", ac.Ldap.BindQueryUser) + assert.Equal(t, "Servers", ac.Ldap.Servers) + assert.Equal(t, "BindMethod", ac.Ldap.BindMethod) + + if err := l.DisableDeploymentAuthentication(); err != nil { + t.Fatal(err) + } + + assert.NotContains(t, ac.Auth.DeploymentAuthMechanisms, string(LDAPPlain)) + assert.Nil(t, ac.Ldap) +} + +func TestLdapEnableAgentAuthentication(t *testing.T) { + conn, ac := createConnectionAndAutomationConfig() + options := Options{ + AgentMechanism: "LDAP", + UserOptions: UserOptions{ + AutomationSubject: ("mms-automation"), + }, + } + + l := NewLdap(conn, ac, options) + + if err := l.EnableAgentAuthentication(Options{AuthoritativeSet: true, AutoPwd: "LDAPPassword."}, zap.S()); err != nil { + t.Fatal(err) + } + + ac, err := conn.ReadAutomationConfig() + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, ac.Auth.AutoUser, options.AutomationSubject) + assert.Len(t, ac.Auth.AutoAuthMechanisms, 1) + assert.Contains(t, ac.Auth.AutoAuthMechanisms, string(LDAPPlain)) + assert.Equal(t, "LDAPPassword.", ac.Auth.AutoPwd) + assert.False(t, ac.Auth.Disabled) + + assert.True(t, ac.Auth.AuthoritativeSet) + +} + +func TestLDAP_DisableAgentAuthentication(t *testing.T) { + conn, ac := createConnectionAndAutomationConfig() + opts := Options{ + AutoPwd: "LDAPPassword.", + UserOptions: UserOptions{ + AutomationSubject: validSubject("automation"), + }, + } + ldap := NewLdap(conn, ac, opts) + assertAgentAuthenticationDisabled(t, ldap, opts) +} diff --git a/controllers/operator/authentication/pkix.go b/controllers/operator/authentication/pkix.go new file mode 100644 index 000000000..643e71a31 --- /dev/null +++ b/controllers/operator/authentication/pkix.go @@ -0,0 +1,262 @@ +package authentication + +import ( + "bytes" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/hex" + "encoding/pem" + "errors" + "strings" + "unicode/utf8" + + "golang.org/x/xerrors" +) + +type encodeState struct { + bytes.Buffer // accumulated output +} + +const ( + commonNameOID = "2.5.4.3" + serialNumberOID = "2.5.4.5" + countryNameOID = "2.5.4.6" + localityNameOID = "2.5.4.7" + stateOrProvinceNameOID = "2.5.4.8" + streetAddressOID = "2.5.4.9" + organizationNameOID = "2.5.4.10" + organizationUnitNameOID = "2.5.4.11" + postalCodeOID = "2.5.4.17" + useridOID = "0.9.2342.19200300.100.1.1" + domainComponentOID = "0.9.2342.19200300.100.1.25" + emailAddressOID = "1.2.840.113549.1.9.1" + subjectAltNameOID = "2.5.29.17" + businessCategoryOID = "2.5.4.15" +) + +var shortNamesByOID = map[string]string{ + commonNameOID: "CN", + serialNumberOID: "serialNumber", + countryNameOID: "C", + localityNameOID: "L", + stateOrProvinceNameOID: "ST", + streetAddressOID: "street", + organizationNameOID: "O", + organizationUnitNameOID: "OU", + postalCodeOID: "postalCode", + useridOID: "UID", + domainComponentOID: "DC", + emailAddressOID: "emailAddress", + subjectAltNameOID: "subjectAltName", + businessCategoryOID: "businessCategory", +} + +func GetCertificateSubject(certPEM string) (subject string, unknownOIDs []string, err error) { + rem := []byte(certPEM) + block, _ := pem.Decode(rem) + if block == nil { + return "", []string{}, xerrors.Errorf("no certificate found") + } + + x509Cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return "", []string{}, err + } + + var subjectRDN pkix.RDNSequence + if _, err := asn1.Unmarshal(x509Cert.RawSubject, &subjectRDN); err != nil { + return "", []string{}, err + } + + var enc encodeState + unknownOIDs, err = enc.writeDistinguishedName(subjectRDN) + if err != nil { + return "", []string{}, err + } + return enc.String(), unknownOIDs, nil +} + +func (enc *encodeState) writeDistinguishedName(subject pkix.RDNSequence) (allUnknownOIDs []string, err error) { + // Section 2.1. Converting the RDNSequence + // + // If the RDNSequence is an empty sequence, the result is the empty or + // zero length string. + // + // Otherwise, the output consists of the string encodings of each + // RelativeDistinguishedName in the RDNSequence (according to 2.2), + // starting with the last element of the sequence and moving backwards + // toward the first. + // + // The encodings of adjoining RelativeDistinguishedNames are separated + // by a comma character (',' ASCII 44). + + allUnknownOIDs = make([]string, 0) + + for i := len(subject) - 1; i >= 0; i-- { + if i < len(subject)-1 { + enc.WriteByte(',') + } + + unknownOIDs, err := enc.writeRelativeDistinguishedName(subject[i]) + if err != nil { + return []string{}, err + } + allUnknownOIDs = append(allUnknownOIDs, unknownOIDs...) + } + return allUnknownOIDs, nil +} +func (enc *encodeState) writeRelativeDistinguishedName(rdn pkix.RelativeDistinguishedNameSET) (unknownOIDs []string, err error) { + if len(rdn) == 0 { + return []string{}, errors.New("expected RelativeDistinguishedNameSET to contain at least 1 attribute") + } + + // 2.2. Converting RelativeDistinguishedName + // + // When converting from an ASN.1 RelativeDistinguishedName to a string, + // the output consists of the string encodings of each + // AttributeTypeAndValue (according to 2.3), in any order. + // + // Where there is a multi-valued RDN, the outputs from adjoining + // AttributeTypeAndValues are separated by a plus ('+' ASCII 43) + // character. + + // TODO: This does not conform to the same order of attributes that OpenSSL uses + + unknownOIDs = make([]string, 0) + for i := 0; i < len(rdn); i++ { + if i > 0 { + enc.WriteByte('+') + } + + found, err := enc.writeAttributeTypeAndValue(rdn[i]) + if err != nil { + return []string{}, err + } + if !found { + unknownOIDs = append(unknownOIDs, rdn[i].Type.String()) + } + } + return unknownOIDs, nil +} +func (enc *encodeState) getAttributeTypeShortName(attrType asn1.ObjectIdentifier) (shortName string, found bool) { + oid := attrType.String() + shortName, found = shortNamesByOID[oid] + if found { + return shortName, true + } + + return "", false +} + +func (enc *encodeState) writeAttributeTypeAndValue(atv pkix.AttributeTypeAndValue) (found bool, err error) { + // Section 2.3. Converting AttributeTypeAndValue + // + // The AttributeTypeAndValue is encoded as the string representation of + // the AttributeType, followed by an equals character ('=' ASCII 61), + // followed by the string representation of the AttributeValue. + + attrType, found := enc.getAttributeTypeShortName(atv.Type) + if err != nil { + // Technically, the AttributeType should be encoded using the dotted-decimal + // notation of its object identifier (OID); however, chances are good that + // it is exists in OpenSSL's list, so our authentication attempt would get + // rejected by the server anyway. + return false, err + } + + attrValue, ok := atv.Value.(string) + if !ok { + // Technically, the AttributeValue should be encoded as an octothorpe + // character ('#' ASCII 35) followed by the hexadecimal representation + // of the bytes from the BER encoding. However, there is no need to + // handle that case because all of the recognized attributes are of type + // IA5String, PrintableString, or UTF8String. + return false, xerrors.Errorf("value for attribute type `%v` was not a string: %v", atv.Type, atv.Value) + } + + if found { + enc.WriteString(attrType) + } else { + enc.WriteString(atv.Type.String()) + } + enc.WriteByte('=') + enc.writeEscapedAttributeValue(attrValue) + return found, nil +} + +func (enc *encodeState) writeEscapedAttributeValue(attrValue string) error { + // Section 2.4 Converting an AttributeValue from ASN.1 to a String + // + // If the UTF-8 string does not have any of the following characters + // which need escaping, then that string can be used as the string + // representation of the value. + // + // - a space or "#" character occurring at the beginning of the string + // - a space character occurring at the end of the string + // - one of the characters ",", "+", """, "\", "<", ">" or ";" + // + // If a character to be escaped is one of the list shown above, then it + // is prefixed by a backslash ('\' ASCII 92). + // + // Otherwise the character to be escaped is replaced by a backslash and + // two hex digits, which form a single byte in the code of the character. + + start := 0 + for i := 0; i < len(attrValue); { + b := attrValue[i] + // OpenSSL does not actually escape NUL as \00, as it only became required + // to do so in RFC 4514. Instead, the attribute value is terminated before + // the end of line character. For example, the inputs "CN=ab" and "CN=ab\x00c" + // both produce the same certificate request. This should mean that no null + // characters can appear in the UTF-8 string, so our handling of them here + // is irrelevant - but not incorrect. + if b < 0x20 || b == 0x7f || b >= utf8.RuneSelf { + if start < i { + enc.WriteString(attrValue[start:i]) + } + enc.WriteByte('\\') + hexDigits := hex.EncodeToString([]byte{b}) + // OpenSSL uses uppercase hexadecimal digits. + enc.WriteString(strings.ToUpper(hexDigits)) + i++ + start = i + continue + } + + switch b { + case ',', '+', '"', '\\', '<', '>', ';': + if start < i { + enc.WriteString(attrValue[start:i]) + } + enc.WriteByte('\\') + enc.WriteByte(b) + i++ + start = i + continue + } + + if i == 0 && (b == ' ' || b == '#') { + // OpenSSL only escapes the first space or hash character, not all leading ones. + enc.WriteByte('\\') + enc.WriteByte(b) + i++ + start = i + } else if i == len(attrValue)-1 && b == ' ' { + // OpenSSL only escapes the last space character, not all trailing ones. + if start < i { + enc.WriteString(attrValue[start:i]) + } + enc.WriteByte('\\') + enc.WriteByte(b) + i++ + start = i + } else { + i++ + } + } + if start < len(attrValue) { + enc.WriteString(attrValue[start:]) + } + return nil +} diff --git a/controllers/operator/authentication/scramsha.go b/controllers/operator/authentication/scramsha.go new file mode 100644 index 000000000..2f3a8a3eb --- /dev/null +++ b/controllers/operator/authentication/scramsha.go @@ -0,0 +1,157 @@ +package authentication + +import ( + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" + "go.uber.org/zap" +) + +func NewConnectionScramSha256(conn om.Connection, ac *om.AutomationConfig) ConnectionScramSha { + return ConnectionScramSha{ + Conn: conn, + AutomationConfigScramSha: AutomationConfigScramSha{ + automationConfig: ac, + mechanismName: ScramSha256, + }, + } +} + +func NewConnectionCR(conn om.Connection, ac *om.AutomationConfig) ConnectionScramSha { + return ConnectionScramSha{ + Conn: conn, + AutomationConfigScramSha: AutomationConfigScramSha{ + automationConfig: ac, + mechanismName: MongoDBCR, + }, + } +} + +func NewConnectionScramSha1(conn om.Connection, ac *om.AutomationConfig) ConnectionScramSha { + return ConnectionScramSha{ + Conn: conn, + AutomationConfigScramSha: AutomationConfigScramSha{ + automationConfig: ac, + mechanismName: ScramSha1, + }, + } +} + +func NewAutomationConfigScramSha1(ac *om.AutomationConfig) AutomationConfigScramSha { + return AutomationConfigScramSha{ + automationConfig: ac, + mechanismName: MongoDBCR, + } +} + +func NewAutomationConfigScramSha256(ac *om.AutomationConfig) AutomationConfigScramSha { + return AutomationConfigScramSha{ + automationConfig: ac, + mechanismName: ScramSha256, + } +} + +// AutomationConfigScramSha applies all the changes required to configure SCRAM-SHA authentication +// directly to an AutomationConfig struct. This implementation does not communicate with Ops Manager in any way. +type AutomationConfigScramSha struct { + mechanismName MechanismName + automationConfig *om.AutomationConfig +} + +func (s AutomationConfigScramSha) EnableAgentAuthentication(opts Options, log *zap.SugaredLogger) error { + if err := configureScramAgentUsers(s.automationConfig, opts); err != nil { + return err + } + if err := s.automationConfig.EnsureKeyFileContents(); err != nil { + return err + } + + auth := s.automationConfig.Auth + auth.Disabled = false + auth.AuthoritativeSet = opts.AuthoritativeSet + auth.KeyFile = util.AutomationAgentKeyFilePathInContainer + auth.KeyFileWindows = util.AutomationAgentWindowsKeyFilePath + + // We can only have a single agent authentication mechanism specified at a given time + auth.AutoAuthMechanisms = []string{string(s.mechanismName)} + return nil +} + +func (s AutomationConfigScramSha) DisableAgentAuthentication(log *zap.SugaredLogger) error { + s.automationConfig.Auth.AutoAuthMechanisms = stringutil.Remove(s.automationConfig.Auth.AutoAuthMechanisms, string(s.mechanismName)) + return nil +} + +func (s AutomationConfigScramSha) DisableDeploymentAuthentication() error { + s.automationConfig.Auth.DeploymentAuthMechanisms = stringutil.Remove(s.automationConfig.Auth.DeploymentAuthMechanisms, string(s.mechanismName)) + return nil +} + +func (s AutomationConfigScramSha) EnableDeploymentAuthentication(Options) error { + auth := s.automationConfig.Auth + if !stringutil.Contains(auth.DeploymentAuthMechanisms, string(s.mechanismName)) { + auth.DeploymentAuthMechanisms = append(auth.DeploymentAuthMechanisms, string(s.mechanismName)) + } + return nil +} + +func (s AutomationConfigScramSha) IsAgentAuthenticationConfigured() bool { + ac := s.automationConfig + if ac.Auth.Disabled { + return false + } + + if !stringutil.Contains(ac.Auth.AutoAuthMechanisms, string(s.mechanismName)) { + return false + } + + if ac.Auth.AutoUser != util.AutomationAgentName || (ac.Auth.AutoPwd == "" || ac.Auth.AutoPwd == util.MergoDelete) { + return false + } + + if ac.Auth.Key == "" || ac.Auth.KeyFile == "" || ac.Auth.KeyFileWindows == "" { + return false + } + + return true +} + +func (s AutomationConfigScramSha) IsDeploymentAuthenticationConfigured() bool { + return stringutil.Contains(s.automationConfig.Auth.DeploymentAuthMechanisms, string(s.mechanismName)) +} + +// ConnectionScramSha is a wrapper around AutomationConfigScramSha which pulls the AutomationConfig +// from Ops Manager and sends back the AutomationConfig which has been configured for to enabled SCRAM-SHA +type ConnectionScramSha struct { + AutomationConfigScramSha + Conn om.Connection +} + +func (s ConnectionScramSha) EnableAgentAuthentication(opts Options, log *zap.SugaredLogger) error { + return s.Conn.ReadUpdateAutomationConfig(func(ac *om.AutomationConfig) error { + s.automationConfig = ac + return s.AutomationConfigScramSha.EnableAgentAuthentication(opts, log) + }, log) +} + +func (s ConnectionScramSha) DisableAgentAuthentication(log *zap.SugaredLogger) error { + return s.Conn.ReadUpdateAutomationConfig(func(ac *om.AutomationConfig) error { + s.automationConfig = ac + return s.AutomationConfigScramSha.DisableAgentAuthentication(log) + }, log) +} + +// configureScramAgentUsers makes sure that the given automation config always has the correct SCRAM-SHA users +func configureScramAgentUsers(ac *om.AutomationConfig, authOpts Options) error { + agentPassword, err := ac.EnsurePassword() + if err != nil { + return err + } + auth := ac.Auth + if auth.AutoUser == "" { + auth.AutoUser = authOpts.AutoUser + } + auth.AutoPwd = agentPassword + + return nil +} diff --git a/controllers/operator/authentication/scramsha_credentials.go b/controllers/operator/authentication/scramsha_credentials.go new file mode 100644 index 000000000..d80ac4d3e --- /dev/null +++ b/controllers/operator/authentication/scramsha_credentials.go @@ -0,0 +1,181 @@ +package authentication + +import ( + "crypto/hmac" + "crypto/sha1" + "crypto/sha256" + "encoding/base64" + "hash" + + "github.com/10gen/ops-manager-kubernetes/pkg/util/generate" + "golang.org/x/xerrors" + + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/xdg/stringprep" +) + +const ( + clientKeyInput = "Client Key" // specified in RFC 5802 + serverKeyInput = "Server Key" // specified in RFC 5802 + + // using the default MongoDB values for the number of iterations depending on mechanism + scramSha1Iterations = 10000 + scramSha256Iterations = 15000 + + RFC5802MandatedSaltSize = 4 +) + +// The code in this file is largely adapted from the Automation Agent codebase. +// https://github.com/10gen/mms-automation/blob/c108e0319cc05c0d8719ceea91a0424a016db583/go_planner/src/com.tengen/cm/crypto/scram.go + +// ComputeScramShaCreds takes a plain text password and a specified mechanism name and generates +// the ScramShaCreds which will be embedded into a MongoDBUser. +func ComputeScramShaCreds(username, password string, salt []byte, name MechanismName) (*om.ScramShaCreds, error) { + var hashConstructor func() hash.Hash + iterations := 0 + if name == ScramSha256 { + hashConstructor = sha256.New + iterations = scramSha256Iterations + } else if name == MongoDBCR { + hashConstructor = sha1.New + iterations = scramSha1Iterations + + // MONGODB-CR/SCRAM-SHA-1 requires the hash of the password being passed computeScramCredentials + // instead of the plain text password. Generated the same was that Ops Manager does. + // See: https://github.com/10gen/mms/blob/a941f11a81fba4f85a9890eaf27605bd344af2a8/server/src/main/com/xgen/svc/mms/deployment/auth/AuthUser.java#L290 + password = util.MD5Hex(username + ":mongo:" + password) + } else { + return nil, xerrors.Errorf("unrecognized SCRAM-SHA format %s", name) + } + base64EncodedSalt := base64.StdEncoding.EncodeToString(salt) + return computeScramCredentials(hashConstructor, iterations, base64EncodedSalt, password) +} + +// GenerateSalt will create a salt for use with ComputeScramShaCreds based on the given hashConstructor. +// sha1.New should be used for MONGODB-CR/SCRAM-SHA-1 and sha256.New should be used for SCRAM-SHA-256 +func GenerateSalt(hashConstructor func() hash.Hash) ([]byte, error) { + saltSize := hashConstructor().Size() - RFC5802MandatedSaltSize + salt, err := generate.RandomFixedLengthStringOfSize(saltSize) + if err != nil { + return nil, err + } + return []byte(salt), nil +} + +func generateSaltedPassword(hashConstructor func() hash.Hash, password string, salt []byte, iterationCount int) ([]byte, error) { + preparedPassword, err := stringprep.SASLprep.Prepare(password) + if err != nil { + return nil, xerrors.Errorf("error SASLprep'ing password: %w", err) + } + + result, err := hmacIteration(hashConstructor, []byte(preparedPassword), salt, iterationCount) + if err != nil { + return nil, xerrors.Errorf("error running hmacIteration: %w", err) + } + return result, nil +} + +func hmacIteration(hashConstructor func() hash.Hash, input, salt []byte, iterationCount int) ([]byte, error) { + hashSize := hashConstructor().Size() + + // incorrect salt size will pass validation, but the credentials will be invalid. i.e. it will not + // be possible to auth with the password provided to create the credentials. + if len(salt) != hashSize-RFC5802MandatedSaltSize { + return nil, xerrors.Errorf("salt should have a size of %v bytes, but instead has a size of %v bytes", hashSize-RFC5802MandatedSaltSize, len(salt)) + } + + startKey := append(salt, 0, 0, 0, 1) + result := make([]byte, hashSize) + + hmacHash := hmac.New(hashConstructor, input) + if _, err := hmacHash.Write(startKey); err != nil { + return nil, xerrors.Errorf("error running hmacHash: %w", err) + } + + intermediateDigest := hmacHash.Sum(nil) + + copy(result, intermediateDigest) + + for i := 1; i < iterationCount; i++ { + hmacHash.Reset() + if _, err := hmacHash.Write(intermediateDigest); err != nil { + return nil, xerrors.Errorf("error running hmacHash: %w", err) + } + + intermediateDigest = hmacHash.Sum(nil) + + for i := 0; i < len(intermediateDigest); i++ { + result[i] ^= intermediateDigest[i] + } + } + + return result, nil +} + +func generateClientOrServerKey(hashConstructor func() hash.Hash, saltedPassword []byte, input string) ([]byte, error) { + hmacHash := hmac.New(hashConstructor, saltedPassword) + if _, err := hmacHash.Write([]byte(input)); err != nil { + return nil, xerrors.Errorf("error running hmacHash: %w", err) + } + + return hmacHash.Sum(nil), nil +} + +func generateStoredKey(hashConstructor func() hash.Hash, clientKey []byte) ([]byte, error) { + h := hashConstructor() + if _, err := h.Write(clientKey); err != nil { + return nil, xerrors.Errorf("error hashing: %w", err) + } + return h.Sum(nil), nil +} + +func generateSecrets(hashConstructor func() hash.Hash, password string, salt []byte, iterationCount int) (storedKey, serverKey []byte, err error) { + saltedPassword, err := generateSaltedPassword(hashConstructor, password, salt, iterationCount) + if err != nil { + return nil, nil, xerrors.Errorf("error generating salted password: %w", err) + } + + clientKey, err := generateClientOrServerKey(hashConstructor, saltedPassword, clientKeyInput) + if err != nil { + return nil, nil, xerrors.Errorf("error generating client key: %w", err) + } + + storedKey, err = generateStoredKey(hashConstructor, clientKey) + if err != nil { + return nil, nil, xerrors.Errorf("error generating stored key: %w", err) + } + + serverKey, err = generateClientOrServerKey(hashConstructor, saltedPassword, serverKeyInput) + if err != nil { + return nil, nil, xerrors.Errorf("error generating server key: %w", err) + } + + return storedKey, serverKey, err +} + +func generateB64EncodedSecrets(hashConstructor func() hash.Hash, password, b64EncodedSalt string, iterationCount int) (storedKey, serverKey string, err error) { + salt, err := base64.StdEncoding.DecodeString(b64EncodedSalt) + if err != nil { + return "", "", xerrors.Errorf("error decoding salt: %w", err) + } + + unencodedStoredKey, unencodedServerKey, err := generateSecrets(hashConstructor, password, salt, iterationCount) + if err != nil { + return "", "", xerrors.Errorf("error generating secrets: %w", err) + } + + storedKey = base64.StdEncoding.EncodeToString(unencodedStoredKey) + serverKey = base64.StdEncoding.EncodeToString(unencodedServerKey) + return storedKey, serverKey, nil +} + +// password should be encrypted in the case of SCRAM-SHA-1 and unencrypted in the case of SCRAM-SHA-256 +func computeScramCredentials(hashConstructor func() hash.Hash, iterationCount int, base64EncodedSalt string, password string) (*om.ScramShaCreds, error) { + storedKey, serverKey, err := generateB64EncodedSecrets(hashConstructor, password, base64EncodedSalt, iterationCount) + if err != nil { + return nil, xerrors.Errorf("error generating SCRAM-SHA keys: %w", err) + } + + return &om.ScramShaCreds{IterationCount: iterationCount, Salt: base64EncodedSalt, StoredKey: storedKey, ServerKey: serverKey}, nil +} diff --git a/controllers/operator/authentication/scramsha_credentials_test.go b/controllers/operator/authentication/scramsha_credentials_test.go new file mode 100644 index 000000000..c4318f5ef --- /dev/null +++ b/controllers/operator/authentication/scramsha_credentials_test.go @@ -0,0 +1,25 @@ +package authentication + +import ( + "crypto/sha1" + "hash" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestScramSha1SecretsMatch(t *testing.T) { + // these were taken from MongoDB. passwordHash is from authSchema + // 3. iterationCount, salt, storedKey, and serverKey are from + // authSchema 5 (after upgrading from authSchema 3) + assertSecretsMatch(t, sha1.New, "caeec61ba3b15b15b188d29e876514e8", 10, "S3cuk2Rnu/MlbewzxrmmVA==", "sYBa3XlSPKNrgjzhOuEuRlJY4dQ=", "zuAxRSQb3gZkbaB1IGlusK4jy1M=") + assertSecretsMatch(t, sha1.New, "4d9625b297999b3ca786d4a9622d04f1", 10, "kW9KbCQiCOll5Ljd44cjkQ==", "VJ8fFVHkPltibvT//mG/OWw44Hc=", "ceDRsgj9HezpZ4/vkZX8GZNNN50=") + assertSecretsMatch(t, sha1.New, "fd0a78e418dcef39f8c768222810b894", 10, "hhX6xsoID6FeWjXncuNgAg==", "TxgaZJ4cIn+S9EfTcc9IOEG7RGc=", "d6/qjwBs0qkPKfUAjSh5eemsySE=") +} + +func assertSecretsMatch(t *testing.T, hash func() hash.Hash, passwordHash string, iterationCount int, salt, storedKey, serverKey string) { + computedStoredKey, computedServerKey, err := generateB64EncodedSecrets(hash, passwordHash, salt, iterationCount) + assert.NoError(t, err) + assert.Equal(t, computedStoredKey, storedKey) + assert.Equal(t, computedServerKey, serverKey) +} diff --git a/controllers/operator/authentication/scramsha_test.go b/controllers/operator/authentication/scramsha_test.go new file mode 100644 index 000000000..9f0754691 --- /dev/null +++ b/controllers/operator/authentication/scramsha_test.go @@ -0,0 +1,74 @@ +package authentication + +import ( + "testing" + + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/stretchr/testify/assert" + "go.uber.org/zap" +) + +func TestAgentsAuthentication(t *testing.T) { + type ConnectionFunction func(om.Connection, *om.AutomationConfig) Mechanism + type TestConfig struct { + connection ConnectionFunction + mechanismsUsed []MechanismName + } + tests := map[string]TestConfig{ + "SCRAM-SHA-1": { + connection: func(connection om.Connection, config *om.AutomationConfig) Mechanism { + return NewConnectionScramSha1(connection, config) + }, + mechanismsUsed: []MechanismName{ScramSha1}, + }, + "SCRAM-SHA-256": { + connection: func(connection om.Connection, config *om.AutomationConfig) Mechanism { + return NewConnectionScramSha256(connection, config) + }, + mechanismsUsed: []MechanismName{ScramSha256}, + }, + "CR": { + connection: func(connection om.Connection, config *om.AutomationConfig) Mechanism { + return NewConnectionCR(connection, config) + }, + mechanismsUsed: []MechanismName{MongoDBCR}, + }, + } + for testName, testConfig := range tests { + t.Run(testName, func(t *testing.T) { + conn, ac := createConnectionAndAutomationConfig() + + s := testConfig.connection(conn, ac) + + err := s.EnableAgentAuthentication(Options{AuthoritativeSet: true}, zap.S()) + assert.NoError(t, err) + + err = s.EnableDeploymentAuthentication(Options{CAFilePath: util.CAFilePathInContainer}) + assert.NoError(t, err) + + ac, err = conn.ReadAutomationConfig() + assert.NoError(t, err) + + assertAuthenticationEnabled(t, ac.Auth) + assert.Equal(t, ac.Auth.AutoUser, util.AutomationAgentName) + assert.Len(t, ac.Auth.AutoAuthMechanisms, 1) + for _, mech := range testConfig.mechanismsUsed { + assert.Contains(t, ac.Auth.AutoAuthMechanisms, string(mech)) + } + assert.NotEmpty(t, ac.Auth.AutoPwd) + assert.True(t, s.IsAgentAuthenticationConfigured()) + assert.True(t, s.IsDeploymentAuthenticationConfigured()) + }) + } +} + +func TestScramSha1_DisableAgentAuthentication(t *testing.T) { + conn, ac := createConnectionAndAutomationConfig() + assertAgentAuthenticationDisabled(t, NewConnectionScramSha1(conn, ac), Options{}) +} + +func TestScramSha256_DisableAgentAuthentication(t *testing.T) { + conn, ac := createConnectionAndAutomationConfig() + assertAgentAuthenticationDisabled(t, NewConnectionScramSha256(conn, ac), Options{}) +} diff --git a/controllers/operator/authentication/x509.go b/controllers/operator/authentication/x509.go new file mode 100644 index 000000000..89b956595 --- /dev/null +++ b/controllers/operator/authentication/x509.go @@ -0,0 +1,190 @@ +package authentication + +import ( + "regexp" + "strings" + + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/statefulset" + + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "go.uber.org/zap" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" +) + +const ExternalDB = "$external" + +func NewConnectionX509(conn om.Connection, ac *om.AutomationConfig, opts Options) ConnectionX509 { + return ConnectionX509{ + AutomationConfig: ac, + Conn: conn, + Options: opts, + } +} + +type ConnectionX509 struct { + AutomationConfig *om.AutomationConfig + Conn om.Connection + Options Options +} + +func (x ConnectionX509) EnableAgentAuthentication(opts Options, log *zap.SugaredLogger) error { + log.Info("Configuring x509 authentication") + err := x.Conn.ReadUpdateAutomationConfig(func(ac *om.AutomationConfig) error { + if err := ac.EnsureKeyFileContents(); err != nil { + return err + } + auth := ac.Auth + auth.AutoPwd = util.MergoDelete + auth.Disabled = false + auth.AuthoritativeSet = opts.AuthoritativeSet + auth.KeyFile = util.AutomationAgentKeyFilePathInContainer + auth.KeyFileWindows = util.AutomationAgentWindowsKeyFilePath + ac.AgentSSL = &om.AgentSSL{ + AutoPEMKeyFilePath: util.AutomationAgentPemFilePath, + CAFilePath: opts.CAFilePath, + ClientCertificateMode: opts.ClientCertificates, + } + + auth.AutoUser = x.Options.AutomationSubject + auth.LdapGroupDN = opts.AutoLdapGroupDN + auth.AutoAuthMechanisms = []string{string(MongoDBX509)} + + return nil + }, log) + + if err != nil { + return err + } + + log.Info("Configuring backup agent user") + err = x.Conn.ReadUpdateBackupAgentConfig(func(config *om.BackupAgentConfig) error { + config.EnableX509Authentication(opts.AutomationSubject) + config.SetLdapGroupDN(opts.AutoLdapGroupDN) + return nil + }, log) + + if err != nil { + return err + } + + log.Info("Configuring monitoring agent user") + return x.Conn.ReadUpdateMonitoringAgentConfig(func(config *om.MonitoringAgentConfig) error { + config.EnableX509Authentication(opts.AutomationSubject) + config.SetLdapGroupDN(opts.AutoLdapGroupDN) + return nil + }, log) +} + +func (x ConnectionX509) DisableAgentAuthentication(log *zap.SugaredLogger) error { + err := x.Conn.ReadUpdateAutomationConfig(func(ac *om.AutomationConfig) error { + + ac.AgentSSL = &om.AgentSSL{ + AutoPEMKeyFilePath: util.MergoDelete, + ClientCertificateMode: util.OptionalClientCertficates, + } + + if stringutil.Contains(ac.Auth.AutoAuthMechanisms, string(MongoDBX509)) { + ac.Auth.AutoAuthMechanisms = stringutil.Remove(ac.Auth.AutoAuthMechanisms, string(MongoDBX509)) + } + return nil + + }, log) + if err != nil { + return err + } + err = x.Conn.ReadUpdateMonitoringAgentConfig(func(config *om.MonitoringAgentConfig) error { + config.DisableX509Authentication() + return nil + }, log) + + if err != nil { + return err + } + + return x.Conn.ReadUpdateBackupAgentConfig(func(config *om.BackupAgentConfig) error { + config.DisableX509Authentication() + return nil + }, log) +} + +func (x ConnectionX509) EnableDeploymentAuthentication(opts Options) error { + ac := x.AutomationConfig + if !stringutil.Contains(ac.Auth.DeploymentAuthMechanisms, util.AutomationConfigX509Option) { + ac.Auth.DeploymentAuthMechanisms = append(ac.Auth.DeploymentAuthMechanisms, string(MongoDBX509)) + } + // AutomationConfig validation requires the CAFile path to be specified in the case of multiple auth + // mechanisms enabled. This is not required if only X509 is being configured + ac.AgentSSL.CAFilePath = opts.CAFilePath + return nil +} + +func (x ConnectionX509) DisableDeploymentAuthentication() error { + ac := x.AutomationConfig + ac.Auth.DeploymentAuthMechanisms = stringutil.Remove(ac.Auth.DeploymentAuthMechanisms, string(MongoDBX509)) + return nil +} + +func (x ConnectionX509) IsAgentAuthenticationConfigured() bool { + ac := x.AutomationConfig + if ac.Auth.Disabled { + return false + } + + if !stringutil.Contains(ac.Auth.AutoAuthMechanisms, string(MongoDBX509)) { + return false + } + + if !isValidX509Subject(ac.Auth.AutoUser) { + return false + } + + if ac.Auth.Key == "" || ac.Auth.KeyFile == "" || ac.Auth.KeyFileWindows == "" { + return false + } + + return true +} + +func (x ConnectionX509) IsDeploymentAuthenticationConfigured() bool { + return stringutil.Contains(x.AutomationConfig.Auth.DeploymentAuthMechanisms, string(MongoDBX509)) +} + +// isValidX509Subject checks the subject contains CommonName, Country and Organizational Unit, Location and State. +func isValidX509Subject(subject string) bool { + expected := []string{"CN", "C", "OU"} + for _, name := range expected { + matched, err := regexp.MatchString(name+`=\w+`, subject) + if err != nil { + continue + } + if !matched { + return false + } + } + return true +} + +// canEnableX509 determines if it's possible to enable/disable x509 configuration options in the current +// version of Ops Manager +func canEnableX509(conn om.Connection) bool { + err := conn.ReadUpdateMonitoringAgentConfig(func(config *om.MonitoringAgentConfig) error { + return nil + }, nil) + if err != nil && strings.Contains(err.Error(), util.MethodNotAllowed) { + return false + } + return true +} + +func ConfigureStatefulSetSecret(sts *appsv1.StatefulSet, secretName string) { + secretVolume := statefulset.CreateVolumeFromSecret(util.AgentSecretName, secretName) + sts.Spec.Template.Spec.Containers[0].VolumeMounts = append(sts.Spec.Template.Spec.Containers[0].VolumeMounts, corev1.VolumeMount{ + MountPath: "/mongodb-automation/" + util.AgentSecretName, + Name: secretVolume.Name, + ReadOnly: true, + }) + sts.Spec.Template.Spec.Volumes = append(sts.Spec.Template.Spec.Volumes, secretVolume) +} diff --git a/controllers/operator/authentication/x509_test.go b/controllers/operator/authentication/x509_test.go new file mode 100644 index 000000000..14c67a554 --- /dev/null +++ b/controllers/operator/authentication/x509_test.go @@ -0,0 +1,64 @@ +package authentication + +import ( + "fmt" + "testing" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/stretchr/testify/assert" + "go.uber.org/zap" +) + +func TestX509EnableAgentAuthentication(t *testing.T) { + conn, ac := createConnectionAndAutomationConfig() + options := Options{ + AgentMechanism: "X509", + ClientCertificates: util.RequireClientCertificates, + UserOptions: UserOptions{ + AutomationSubject: validSubject("automation"), + }, + } + x := NewConnectionX509(conn, ac, options) + if err := x.EnableAgentAuthentication(Options{AuthoritativeSet: true}, zap.S()); err != nil { + t.Fatal(err) + } + + ac, err := conn.ReadAutomationConfig() + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, ac.Auth.AutoUser, options.AutomationSubject) + assert.Len(t, ac.Auth.AutoAuthMechanisms, 1) + assert.Contains(t, ac.Auth.AutoAuthMechanisms, string(MongoDBX509)) + assert.Equal(t, ac.Auth.AutoPwd, util.MergoDelete) + assert.False(t, ac.Auth.Disabled) + assert.Len(t, ac.Auth.Users, 0) + + assert.True(t, ac.Auth.AuthoritativeSet) + assert.NotEmpty(t, ac.Auth.Key) + assert.NotEmpty(t, ac.Auth.KeyFileWindows) + assert.NotEmpty(t, ac.Auth.KeyFile) + +} + +func TestX509_DisableAgentAuthentication(t *testing.T) { + conn, ac := createConnectionAndAutomationConfig() + opts := Options{ + UserOptions: UserOptions{ + AutomationSubject: validSubject("automation"), + }, + } + x509 := NewConnectionX509(conn, ac, opts) + assertAgentAuthenticationDisabled(t, x509, opts) +} + +func TestX509_DeploymentConfigured(t *testing.T) { + conn, ac := createConnectionAndAutomationConfig() + assertDeploymentMechanismsConfigured(t, NewConnectionX509(conn, ac, Options{AgentMechanism: "SCRAM"})) + assert.Equal(t, ac.AgentSSL.CAFilePath, util.CAFilePathInContainer) +} + +func validSubject(o string) string { + return fmt.Sprintf("CN=mms-automation-agent,OU=MongoDB Kubernetes Operator,O=%s,L=NY,ST=NY,C=US", o) +} diff --git a/controllers/operator/authentication_test.go b/controllers/operator/authentication_test.go new file mode 100644 index 000000000..44da5b601 --- /dev/null +++ b/controllers/operator/authentication_test.go @@ -0,0 +1,941 @@ +package operator + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "io/ioutil" + "math/big" + "testing" + "time" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/deployment" + + "k8s.io/utils/pointer" + + certsv1 "k8s.io/api/certificates/v1beta1" + + kubernetesClient "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/client" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/secret" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/api/v1/mdbmulti" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/authentication" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/certs" + + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/mock" + + "github.com/stretchr/testify/assert" + "go.uber.org/zap" + + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/pkg/dns" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +func TestX509CanBeEnabled_WhenThereAreOnlyTlsDeployments_ReplicaSet(t *testing.T) { + rs := DefaultReplicaSetBuilder().EnableTLS().EnableX509().SetTLSCA("custom-ca").Build() + manager := mock.NewManager(rs) + createConfigMap(t, manager.Client) + + addKubernetesTlsResources(manager.Client, rs) + + reconciler := newReplicaSetReconciler(manager, om.NewEmptyMockedOmConnection) + checkReconcileSuccessful(t, reconciler, rs, manager.Client) +} + +func TestX509ClusterAuthentication_CanBeEnabled_IfX509AuthenticationIsEnabled_ReplicaSet(t *testing.T) { + rs := DefaultReplicaSetBuilder().EnableTLS().EnableX509().SetTLSCA("custom-ca").Build() + manager := mock.NewManager(rs) + addKubernetesTlsResources(manager.Client, rs) + createConfigMap(t, manager.Client) + + reconciler := newReplicaSetReconciler(manager, om.NewEmptyMockedOmConnection) + checkReconcileSuccessful(t, reconciler, rs, manager.Client) +} + +func TestX509ClusterAuthentication_CanBeEnabled_IfX509AuthenticationIsEnabled_ShardedCluster(t *testing.T) { + scWithTls := DefaultClusterBuilder().EnableTLS().EnableX509().SetName("sc-with-tls").SetTLSCA("custom-ca").Build() + reconciler, client := defaultClusterReconciler(scWithTls) + addKubernetesTlsResources(client, scWithTls) + + checkReconcileSuccessful(t, reconciler, scWithTls, client) +} + +func TestX509CanBeEnabled_WhenThereAreOnlyTlsDeployments_ShardedCluster(t *testing.T) { + scWithTls := DefaultClusterBuilder().EnableTLS().EnableX509().SetName("sc-with-tls").SetTLSCA("custom-ca").Build() + + reconciler, client := defaultClusterReconciler(scWithTls) + addKubernetesTlsResources(client, scWithTls) + + checkReconcileSuccessful(t, reconciler, scWithTls, client) +} + +func TestUpdateOmAuthentication_NoAuthenticationEnabled(t *testing.T) { + conn := om.NewMockedOmConnection(om.NewDeployment()) + rs := DefaultReplicaSetBuilder().SetName("my-rs").SetMembers(3).Build() + processNames := []string{"my-rs-0", "my-rs-1", "my-rs-2"} + + r := newReplicaSetReconciler(mock.NewManager(rs), om.NewEmptyMockedOmConnection) + r.updateOmAuthentication(conn, processNames, rs, "", "", "", zap.S()) + + ac, _ := conn.ReadAutomationConfig() + + assert.True(t, ac.Auth.Disabled, "authentication was not specified to enabled, so it should remain disabled in Ops Manager") + assert.Len(t, ac.Auth.Users, 0) +} + +func TestUpdateOmAuthentication_EnableX509_TlsNotEnabled(t *testing.T) { + rs := DefaultReplicaSetBuilder().SetName("my-rs").SetMembers(3).Build() + // deployment with existing non-tls non-x509 replica set + conn := om.NewMockedOmConnection(deployment.CreateFromReplicaSet(rs)) + + // configure X509 authentication & tls + rs.Spec.Security.Authentication.Modes = []string{"X509"} + rs.Spec.Security.Authentication.Enabled = true + rs.Spec.Security.TLSConfig.Enabled = true + + r := newReplicaSetReconciler(mock.NewManager(rs), om.NewEmptyMockedOmConnection) + status, isMultiStageReconciliation := r.updateOmAuthentication(conn, []string{"my-rs-0", "my-rs-1", "my-rs-2"}, rs, "", "", "", zap.S()) + + assert.True(t, status.IsOK(), "configuring both options at once should not result in a failed status") + assert.True(t, isMultiStageReconciliation, "configuring both tls and x509 at once should result in a multi stage reconciliation") +} + +func TestUpdateOmAuthentication_EnableX509_WithTlsAlreadyEnabled(t *testing.T) { + rs := DefaultReplicaSetBuilder().SetName("my-rs").SetMembers(3).EnableTLS().Build() + conn := om.NewMockedOmConnection(deployment.CreateFromReplicaSet(rs)) + r := newReplicaSetReconciler(mock.NewManager(rs), om.NewEmptyMockedOmConnection) + status, isMultiStageReconciliation := r.updateOmAuthentication(conn, []string{"my-rs-0", "my-rs-1", "my-rs-2"}, rs, "", "", "", zap.S()) + + assert.True(t, status.IsOK(), "configuring x509 when tls has already been enabled should not result in a failed status") + assert.False(t, isMultiStageReconciliation, "if tls is already enabled, we should be able to configure x509 is a single reconciliation") +} + +func TestUpdateOmAuthentication_AuthenticationIsNotConfigured_IfAuthIsNotSet(t *testing.T) { + rs := DefaultReplicaSetBuilder().SetName("my-rs").SetMembers(3).EnableTLS().SetAuthentication(nil).Build() + + rs.Spec.Security.Authentication = nil + + conn := om.NewMockedOmConnection(deployment.CreateFromReplicaSet(rs)) + r := newReplicaSetReconciler(mock.NewManager(rs), func(context *om.OMContext) om.Connection { + return conn + }) + + status, _ := r.updateOmAuthentication(conn, []string{"my-rs-0", "my-rs-1", "my-rs-2"}, rs, "", "", "", zap.S()) + assert.True(t, status.IsOK(), "no authentication should have been configured") + + ac, _ := conn.ReadAutomationConfig() + + // authentication has not been touched + assert.True(t, ac.Auth.Disabled) + assert.Len(t, ac.Auth.Users, 0) + assert.Equal(t, "MONGODB-CR", ac.Auth.AutoAuthMechanism) +} + +func TestUpdateOmAuthentication_DoesNotDisableAuth_IfAuthIsNotSet(t *testing.T) { + rs := DefaultReplicaSetBuilder(). + EnableTLS(). + EnableAuth(). + EnableX509(). + SetTLSCA("custom-ca"). + Build() + + manager := mock.NewManager(rs) + manager.Client.AddDefaultMdbConfigResources() + reconciler, client := newReplicaSetReconciler(manager, om.NewEmptyMockedOmConnection), manager.Client + + addKubernetesTlsResources(client, rs) + + checkReconcileSuccessful(t, reconciler, rs, client) + + ac, _ := om.CurrMockedConnection.ReadAutomationConfig() + // x509 auth has been enabled + assert.True(t, ac.Auth.IsEnabled()) + assert.Contains(t, ac.Auth.AutoAuthMechanism, authentication.MongoDBX509) + + rs.Spec.Security.Authentication = nil + + manager = mock.NewManagerSpecificClient(client) + reconciler = newReplicaSetReconciler(manager, func(context *om.OMContext) om.Connection { + return om.CurrMockedConnection + }) + + checkReconcileSuccessful(t, reconciler, rs, client) + + ac, _ = om.CurrMockedConnection.ReadAutomationConfig() + assert.True(t, ac.Auth.IsEnabled()) + assert.Contains(t, ac.Auth.AutoAuthMechanism, authentication.MongoDBX509) +} + +func TestCanConfigureAuthenticationDisabled_WithNoModes(t *testing.T) { + rs := DefaultReplicaSetBuilder(). + EnableTLS(). + SetTLSCA("custom-ca"). + SetAuthentication( + &mdbv1.Authentication{ + Enabled: false, + Modes: nil, + }). + Build() + + manager := mock.NewManager(rs) + manager.Client.AddDefaultMdbConfigResources() + reconciler, client := newReplicaSetReconciler(manager, om.NewEmptyMockedOmConnection), manager.Client + + addKubernetesTlsResources(client, rs) + + checkReconcileSuccessful(t, reconciler, rs, client) +} + +func TestUpdateOmAuthentication_EnableX509_FromEmptyDeployment(t *testing.T) { + conn := om.NewMockedOmConnection(om.NewDeployment()) + + rs := DefaultReplicaSetBuilder().SetName("my-rs").SetMembers(3).EnableTLS().EnableAuth().EnableX509().Build() + r := newReplicaSetReconciler(mock.NewManager(rs), om.NewEmptyMockedOmConnection) + createAgentCSRs(1, r.client, certsv1.CertificateApproved) + + status, isMultiStageReconciliation := r.updateOmAuthentication(conn, []string{"my-rs-0", "my-rs-1", "my-rs-2"}, rs, "", "", "", zap.S()) + assert.True(t, status.IsOK(), "configuring x509 and tls when there are no processes should not result in a failed status") + assert.False(t, isMultiStageReconciliation, "if we are enabling tls and x509 at once, this should be done in a single reconciliation") +} + +func TestX509AgentUserIsCorrectlyConfigured(t *testing.T) { + rs := DefaultReplicaSetBuilder().SetName("my-rs").SetMembers(3).EnableTLS().SetTLSCA("custom-ca").EnableAuth().EnableX509().Build() + x509User := DefaultMongoDBUserBuilder().SetDatabase(authentication.ExternalDB).SetMongoDBResourceName("my-rs").Build() + + manager := mock.NewManager(rs) + manager.Client.AddDefaultMdbConfigResources() + memberClusterMap := getFakeMultiClusterMap() + err := manager.Client.Create(context.TODO(), x509User) + assert.NoError(t, err) + + // configure x509/tls resources + addKubernetesTlsResources(manager.Client, rs) + reconciler := newReplicaSetReconciler(manager, om.NewEmptyMockedOmConnection) + + checkReconcileSuccessful(t, reconciler, rs, manager.Client) + + userReconciler := newMongoDBUserReconciler(manager, func(context *om.OMContext) om.Connection { + return om.CurrMockedConnection // use the same connection + }, memberClusterMap) + + actual, err := userReconciler.Reconcile(context.TODO(), requestFromObject(x509User)) + expected := reconcile.Result{} + + assert.NoError(t, err) + assert.Equal(t, expected, actual) + + ac, _ := om.CurrMockedConnection.ReadAutomationConfig() + assert.Equal(t, ac.Auth.AutoUser, "CN=mms-automation-agent,OU=cloud,O=MongoDB,L=New York,ST=New York,C=US") +} + +func TestScramAgentUserIsCorrectlyConfigured(t *testing.T) { + rs := DefaultReplicaSetBuilder().SetName("my-rs").SetMembers(3).EnableAuth().EnableSCRAM().Build() + scramUser := DefaultMongoDBUserBuilder().SetMongoDBResourceName("my-rs").Build() + + manager := mock.NewManager(rs) + manager.Client.AddDefaultMdbConfigResources() + memberClusterMap := getFakeMultiClusterMap() + err := manager.Client.Create(context.TODO(), scramUser) + assert.NoError(t, err) + + userPassword := secret.Builder(). + SetNamespace(scramUser.Namespace). + SetName(scramUser.Spec.PasswordSecretKeyRef.Name). + SetField(scramUser.Spec.PasswordSecretKeyRef.Key, "password"). + Build() + + err = manager.Client.Create(context.TODO(), &userPassword) + + assert.NoError(t, err) + + reconciler := newReplicaSetReconciler(manager, om.NewEmptyMockedOmConnection) + + checkReconcileSuccessful(t, reconciler, rs, manager.Client) + + userReconciler := newMongoDBUserReconciler(manager, func(context *om.OMContext) om.Connection { + return om.CurrMockedConnection // use the same connection + }, memberClusterMap) + + actual, err := userReconciler.Reconcile(context.TODO(), requestFromObject(scramUser)) + expected := reconcile.Result{} + + assert.NoError(t, err) + assert.Equal(t, expected, actual) + + ac, _ := om.CurrMockedConnection.ReadAutomationConfig() + assert.Equal(t, ac.Auth.AutoUser, util.AutomationAgentName) +} + +func TestScramAgentUser_IsNotOverridden(t *testing.T) { + rs := DefaultReplicaSetBuilder().SetName("my-rs").SetMembers(3).EnableAuth().EnableSCRAM().Build() + manager := mock.NewManager(rs) + manager.Client.AddDefaultMdbConfigResources() + reconciler := newReplicaSetReconciler(manager, om.NewEmptyMockedOmConnection) + reconciler.omConnectionFactory = func(ctx *om.OMContext) om.Connection { + connection := om.NewEmptyMockedOmConnectionWithAutomationConfigChanges(ctx, func(ac *om.AutomationConfig) { + ac.Auth.AutoUser = "my-custom-agent-name" + }) + return connection + } + + checkReconcileSuccessful(t, reconciler, rs, manager.Client) + + ac, _ := om.CurrMockedConnection.ReadAutomationConfig() + + assert.Equal(t, "my-custom-agent-name", ac.Auth.AutoUser) +} + +func TestX509InternalClusterAuthentication_CanBeEnabledWithScram_ReplicaSet(t *testing.T) { + rs := DefaultReplicaSetBuilder().SetName("my-rs"). + SetMembers(3). + EnableAuth(). + EnableSCRAM(). + EnableX509InternalClusterAuth(). + Build() + + manager := mock.NewManager(rs) + r := newReplicaSetReconciler(manager, om.NewEmptyMockedOmConnection) + createConfigMap(t, r.client) + addKubernetesTlsResources(r.client, rs) + + checkReconcileSuccessful(t, r, rs, manager.Client) + + currConn := om.CurrMockedConnection + dep, _ := currConn.ReadDeployment() + for _, p := range dep.ProcessesCopy() { + assert.Equal(t, p.ClusterAuthMode(), "x509") + } +} + +func TestX509InternalClusterAuthentication_CanBeEnabledWithScram_ShardedCluster(t *testing.T) { + sc := DefaultClusterBuilder().SetName("my-sc"). + EnableAuth(). + EnableSCRAM(). + EnableX509InternalClusterAuth(). + Build() + + r, manager := newShardedClusterReconcilerFromResource(*sc, om.NewEmptyMockedOmConnection) + addKubernetesTlsResources(r.client, sc) + createConfigMap(t, manager.Client) + checkReconcileSuccessful(t, r, sc, manager.Client) + + currConn := om.CurrMockedConnection + dep, _ := currConn.ReadDeployment() + for _, p := range dep.ProcessesCopy() { + assert.Equal(t, p.ClusterAuthMode(), "x509") + } +} + +func TestConfigureLdapDeploymentAuthentication_WithScramAgentAuthentication(t *testing.T) { + rs := DefaultReplicaSetBuilder(). + SetName("my-rs"). + SetMembers(3). + SetVersion("4.0.0-ent"). + EnableAuth(). + AgentAuthMode("SCRAM"). + EnableSCRAM(). + EnableLDAP(). + LDAP( + mdbv1.Ldap{ + BindQueryUser: "bindQueryUser", + Servers: []string{"server0:1234", "server1:9876"}, + BindQuerySecretRef: mdbv1.SecretRef{ + Name: "bind-query-password", + }, + TimeoutMS: 10000, + UserCacheInvalidationInterval: 60, + }, + ). + Build() + + manager := mock.NewManager(rs) + manager.Client.AddDefaultMdbConfigResources() + r := newReplicaSetReconciler(manager, om.NewEmptyMockedOmConnection) + data := map[string]string{ + "password": "LITZTOd6YiCV8j", + } + err := secret.CreateOrUpdate(r.client, secret.Builder(). + SetName("bind-query-password"). + SetNamespace(mock.TestNamespace). + SetStringMapToData(data). + Build(), + ) + assert.NoError(t, err) + checkReconcileSuccessful(t, r, rs, manager.Client) + + ac, err := om.CurrMockedConnection.ReadAutomationConfig() + assert.NoError(t, err) + assert.Equal(t, "LITZTOd6YiCV8j", ac.Ldap.BindQueryPassword) + assert.Equal(t, "bindQueryUser", ac.Ldap.BindQueryUser) + assert.Equal(t, "server0:1234,server1:9876", ac.Ldap.Servers) + assert.Equal(t, 10000, ac.Ldap.TimeoutMS) + assert.Equal(t, 60, ac.Ldap.UserCacheInvalidationInterval) + assert.Contains(t, ac.Auth.DeploymentAuthMechanisms, "PLAIN") + assert.Contains(t, ac.Auth.DeploymentAuthMechanisms, "SCRAM-SHA-256") +} + +func TestConfigureLdapDeploymentAuthentication_WithCustomRole(t *testing.T) { + + customRoles := []mdbv1.MongoDbRole{{ + Db: "admin", + Role: "customRole", + Roles: []mdbv1.InheritedRole{{Db: "Admin", Role: "inheritedrole"}}, + Privileges: []mdbv1.Privilege{}}, + } + + rs := DefaultReplicaSetBuilder(). + SetName("my-rs"). + SetMembers(3). + SetVersion("4.0.0-ent"). + EnableAuth(). + AgentAuthMode("SCRAM"). + EnableSCRAM(). + EnableLDAP(). + LDAP( + mdbv1.Ldap{ + BindQueryUser: "bindQueryUser", + Servers: []string{"server0:1234"}, + BindQuerySecretRef: mdbv1.SecretRef{ + Name: "bind-query-password", + }, + }, + ). + SetRoles(customRoles). + Build() + + manager := mock.NewManager(rs) + manager.Client.AddDefaultMdbConfigResources() + r := newReplicaSetReconciler(manager, om.NewEmptyMockedOmConnection) + data := map[string]string{ + "password": "LITZTOd6YiCV8j", + } + err := secret.CreateOrUpdate(r.client, secret.Builder(). + SetName("bind-query-password"). + SetNamespace(mock.TestNamespace). + SetStringMapToData(data). + Build(), + ) + assert.NoError(t, err) + checkReconcileSuccessful(t, r, rs, manager.Client) + + ac, err := om.CurrMockedConnection.ReadAutomationConfig() + assert.NoError(t, err) + assert.Equal(t, "server0:1234", ac.Ldap.Servers) + + roles := ac.Deployment["roles"].([]mdbv1.MongoDbRole) + assert.Len(t, roles, 1) + assert.Equal(t, customRoles, roles) +} + +func TestConfigureLdapDeploymentAuthentication_WithAuthzQueryTemplate_AndUserToDnMapping(t *testing.T) { + + userMapping := `[ + { + match: "(.+)", + substitution: "uid={0},dc=example,dc=org" + } + ]` + authzTemplate := "{USER}?memberOf?base" + rs := DefaultReplicaSetBuilder(). + SetName("my-rs"). + SetMembers(3). + SetVersion("4.0.0-ent"). + EnableAuth(). + AgentAuthMode("SCRAM"). + EnableSCRAM(). + EnableLDAP(). + LDAP( + mdbv1.Ldap{ + BindQueryUser: "bindQueryUser", + Servers: []string{"server0:0000,server1:1111,server2:2222"}, + BindQuerySecretRef: mdbv1.SecretRef{ + Name: "bind-query-password", + }, + AuthzQueryTemplate: authzTemplate, + UserToDNMapping: userMapping, + }, + ). + Build() + + manager := mock.NewManager(rs) + manager.Client.AddDefaultMdbConfigResources() + r := newReplicaSetReconciler(manager, om.NewEmptyMockedOmConnection) + data := map[string]string{ + "password": "LITZTOd6YiCV8j", + } + err := secret.CreateOrUpdate(r.client, secret.Builder(). + SetName("bind-query-password"). + SetNamespace(mock.TestNamespace). + SetStringMapToData(data). + Build(), + ) + assert.NoError(t, err) + checkReconcileSuccessful(t, r, rs, manager.Client) + + ac, err := om.CurrMockedConnection.ReadAutomationConfig() + assert.NoError(t, err) + assert.Equal(t, "server0:0000,server1:1111,server2:2222", ac.Ldap.Servers) + + assert.Equal(t, authzTemplate, ac.Ldap.AuthzQueryTemplate) + assert.Equal(t, userMapping, ac.Ldap.UserToDnMapping) +} + +// addKubernetesTlsResources ensures all the required TLS secrets exist for the given MongoDB resource +func addKubernetesTlsResources(client kubernetesClient.Client, mdb *mdbv1.MongoDB) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: mock.TestCredentialsSecretName, Namespace: mock.TestNamespace}, + Data: map[string][]byte{ + "publicApiKey": []byte("someapi"), + "user": []byte("someuser"), + }, + Type: corev1.SecretTypeTLS, + } + + _ = client.Update(context.TODO(), secret) + switch mdb.Spec.ResourceType { + case mdbv1.ReplicaSet: + createReplicaSetTLSData(client, mdb) + case mdbv1.ShardedCluster: + createShardedClusterTLSData(client, mdb) + } +} + +// createMockCertAndKeyBytesMulti generates a random key and certificate and returns +// them as bytes with the MongoDBMultiCluster service FQDN in the dns names of the certificate. +func createMockCertAndKeyBytesMulti(mdbm mdbmulti.MongoDBMultiCluster, clusterNum, podNum int) []byte { + return createMockCertAndKeyBytesWithDNSName(dns.GetMultiServiceFQDN(mdbm.Name, mock.TestNamespace, clusterNum, podNum)) +} +func createMockCertAndKeyBytesWithDNSName(dnsName string) []byte { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + panic(err) + } + + privBytes, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + panic(err) + } + + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + panic(err) + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"MongoDB"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(10, 0, 0), // cert expires in 10 years + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + DNSNames: []string{dnsName}, + } + certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + panic(err) + } + + certPemBytes := &bytes.Buffer{} + if err := pem.Encode(certPemBytes, &pem.Block{Type: "CERTIFICATE", Bytes: certBytes}); err != nil { + panic(err) + } + + privPemBytes := &bytes.Buffer{} + if err := pem.Encode(privPemBytes, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}); err != nil { + panic(err) + } + + return append(certPemBytes.Bytes(), privPemBytes.Bytes()...) +} + +// createMockCertAndKeyBytes generates a random key and certificate and returns +// them as bytes +func createMockCertAndKeyBytes(certOpts ...func(cert *x509.Certificate)) (cert, key []byte) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + panic(err) + } + + privBytes, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + panic(err) + } + + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + panic(err) + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"MongoDB"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(10, 0, 0), // cert expires in 10 years + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + DNSNames: []string{"somehost.com"}, + } + + for _, opt := range certOpts { + opt(&template) + } + certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + panic(err) + } + + certPemBytes := &bytes.Buffer{} + if err := pem.Encode(certPemBytes, &pem.Block{Type: "CERTIFICATE", Bytes: certBytes}); err != nil { + panic(err) + } + + privPemBytes := &bytes.Buffer{} + if err := pem.Encode(privPemBytes, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}); err != nil { + panic(err) + } + + return certPemBytes.Bytes(), privPemBytes.Bytes() +} + +// createReplicaSetTLSData creates and populates secrets required for a TLS enabled ReplicaSet +func createReplicaSetTLSData(client kubernetesClient.Client, mdb *mdbv1.MongoDB) { + // Lets create a secret with Certificates and private keys! + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-cert", mdb.Name), + Namespace: mock.TestNamespace, + }, + Type: corev1.SecretTypeTLS, + } + + certs := map[string][]byte{} + clientCerts := map[string][]byte{} + + certs["tls.crt"], certs["tls.key"] = createMockCertAndKeyBytes() + clientCerts["tls.crt"], clientCerts["tls.key"] = createMockCertAndKeyBytes() + secret.Data = certs + + _ = client.Create(context.TODO(), secret) + + _ = client.Create(context.TODO(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("%s-clusterfile", mdb.Name), Namespace: mock.TestNamespace}, + Data: clientCerts, + Type: corev1.SecretTypeTLS, + }) + + agentCerts := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "agent-certs", + Namespace: mock.TestNamespace, + }, + Type: corev1.SecretTypeTLS, + } + + subjectModifier := func(cert *x509.Certificate) { + cert.Subject.OrganizationalUnit = []string{"cloud"} + cert.Subject.Locality = []string{"New York"} + cert.Subject.Province = []string{"New York"} + cert.Subject.Country = []string{"US"} + } + + agentCerts.Data = make(map[string][]byte) + agentCerts.Data["tls.crt"], agentCerts.Data["tls.key"] = createMockCertAndKeyBytes(subjectModifier, func(cert *x509.Certificate) { cert.Subject.CommonName = "mms-automation-agent" }) + _ = client.Create(context.TODO(), agentCerts) +} + +// createShardedClusterTLSData creates and populates all the secrets needed for a TLS enabled Sharded +// Cluster with internal cluster authentication. Mongos, config server and all shards. +func createShardedClusterTLSData(client kubernetesClient.Client, mdb *mdbv1.MongoDB) { + // create the secrets for all the shards + for i := 0; i < mdb.Spec.ShardCount; i++ { + secretName := fmt.Sprintf("%s-%d-cert", mdb.Name, i) + shardData := make(map[string][]byte) + shardData["tls.crt"], shardData["tls.key"] = createMockCertAndKeyBytes() + + _ = client.Create(context.TODO(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: mock.TestNamespace}, + Data: shardData, + Type: corev1.SecretTypeTLS, + }) + _ = client.Create(context.TODO(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("%s-%d-clusterfile", mdb.Name, i), Namespace: mock.TestNamespace}, + Data: shardData, + Type: corev1.SecretTypeTLS, + }) + } + + // populate with the expected cert and key fields + mongosData := make(map[string][]byte) + mongosData["tls.crt"], mongosData["tls.key"] = createMockCertAndKeyBytes() + + // create the mongos secret + mongosSecretName := fmt.Sprintf("%s-mongos-cert", mdb.Name) + _ = client.Create(context.TODO(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: mongosSecretName, Namespace: mock.TestNamespace}, + Data: mongosData, + Type: corev1.SecretTypeTLS, + }) + + _ = client.Create(context.TODO(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("%s-mongos-clusterfile", mdb.Name), Namespace: mock.TestNamespace}, + Data: mongosData, + Type: corev1.SecretTypeTLS, + }) + + // create secret for config server + configData := make(map[string][]byte) + configData["tls.crt"], configData["tls.key"] = createMockCertAndKeyBytes() + + _ = client.Create(context.TODO(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("%s-config-cert", mdb.Name), Namespace: mock.TestNamespace}, + Data: configData, + Type: corev1.SecretTypeTLS, + }) + + _ = client.Create(context.TODO(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("%s-config-clusterfile", mdb.Name), Namespace: mock.TestNamespace}, + Data: configData, + Type: corev1.SecretTypeTLS, + }) + agentCerts := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "agent-certs", + Namespace: mock.TestNamespace, + }, + Type: corev1.SecretTypeTLS, + } + + subjectModifier := func(cert *x509.Certificate) { + cert.Subject.OrganizationalUnit = []string{"cloud"} + cert.Subject.Locality = []string{"New York"} + cert.Subject.Province = []string{"New York"} + cert.Subject.Country = []string{"US"} + } + + agentCerts.Data = make(map[string][]byte) + agentCerts.Data["tls.crt"], agentCerts.Data["tls.key"] = createMockCertAndKeyBytes(subjectModifier, func(cert *x509.Certificate) { cert.Subject.CommonName = "mms-automation-agent" }) + _ = client.Create(context.TODO(), agentCerts) + +} + +// createMultiClusterReplicaSetTLSData creates and populates secrets required for a TLS enabled MongoDBMultiCluster ReplicaSet. +func createMultiClusterReplicaSetTLSData(client *mock.MockedClient, mdbm *mdbmulti.MongoDBMultiCluster, caName string) { + // Create CA configmap + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: caName, + Namespace: mock.TestNamespace, + }, + } + cm.Data = map[string]string{ + "ca-pem": "capublickey", + "mms-ca.crt": "capublickey", + } + client.Create(context.TODO(), cm) + // Lets create a secret with Certificates and private keys! + secretName := fmt.Sprintf("%s-cert", mdbm.Name) + if mdbm.Spec.Security.CertificatesSecretsPrefix != "" { + secretName = fmt.Sprintf("%s-%s", mdbm.Spec.Security.CertificatesSecretsPrefix, secretName) + } + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: mock.TestNamespace, + }, + } + + certs := map[string][]byte{} + clientCerts := map[string][]byte{} + + clusterSpecs, err := mdbm.GetClusterSpecItems() + if err != nil { + panic(err) + } + + for _, item := range clusterSpecs { + for podNum := 0; podNum < item.Members; podNum++ { + pemFile := createMockCertAndKeyBytesMulti(*mdbm, mdbm.ClusterNum(item.ClusterName), podNum) + certs[fmt.Sprintf("%s-%d-%d-pem", mdbm.Name, mdbm.ClusterNum(item.ClusterName), podNum)] = pemFile + clientCerts[fmt.Sprintf("%s-%d-%d-pem", mdbm.Name, mdbm.ClusterNum(item.ClusterName), podNum)] = pemFile + } + } + + secret.Data = certs + // create cert in the central cluster, the operator would create the concatenated + // pem cert in the member clusters. + client.Create(context.TODO(), secret) +} + +func createConfigMap(t *testing.T, client kubernetesClient.Client) { + + err := client.CreateConfigMap(configMap()) + assert.NoError(t, err) +} + +func TestInvalidPEM_SecretDoesNotContainKey(t *testing.T) { + rs := DefaultReplicaSetBuilder(). + EnableTLS(). + EnableAuth(). + EnableX509(). + Build() + + manager := mock.NewManager(rs) + + reconciler := newReplicaSetReconciler(manager, om.NewEmptyMockedOmConnection) + client := manager.Client + + addKubernetesTlsResources(client, rs) + + //Replace the secret with an empty one + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-cert", rs.Name), + Namespace: mock.TestNamespace, + }, + Type: corev1.SecretTypeTLS, + } + + _ = client.Update(context.TODO(), secret) + + err := certs.VerifyAndEnsureCertificatesForStatefulSet(reconciler.SecretClient, reconciler.SecretClient, fmt.Sprintf("%s-cert", rs.Name), certs.ReplicaSetConfig(*rs), nil) + assert.Equal(t, err.Error(), "the certificate is not complete\n") + +} + +func Test_NoAdditionalDomainsPresent(t *testing.T) { + rs := DefaultReplicaSetBuilder(). + EnableTLS(). + EnableAuth(). + EnableX509(). + Build() + + // The default secret we create does not contain additional domains so it will not be valid for this RS + rs.Spec.Security.TLSConfig.AdditionalCertificateDomains = []string{"foo"} + + manager := mock.NewManager(rs) + reconciler := newReplicaSetReconciler(manager, om.NewEmptyMockedOmConnection) + client := manager.Client + + addKubernetesTlsResources(client, rs) + + secret := &corev1.Secret{} + + _ = client.Get(context.TODO(), types.NamespacedName{Name: fmt.Sprintf("%s-cert", rs.Name), Namespace: rs.Namespace}, secret) + + err := certs.VerifyAndEnsureCertificatesForStatefulSet(reconciler.SecretClient, reconciler.SecretClient, fmt.Sprintf("%s-cert", rs.Name), certs.ReplicaSetConfig(*rs), nil) + for i := 0; i < rs.Spec.Members; i++ { + expectedErrorMessage := fmt.Sprintf("domain %s-%d.foo is not contained in the list of DNSNames", rs.Name, i) + assert.Contains(t, err.Error(), expectedErrorMessage) + } +} + +func Test_NoExternalDomainPresent(t *testing.T) { + rs := DefaultReplicaSetBuilder(). + EnableTLS(). + EnableAuth(). + EnableX509(). + Build() + + rs.Spec.ExternalAccessConfiguration = &mdbv1.ExternalAccessConfiguration{ExternalDomain: pointer.String("foo")} + + manager := mock.NewManager(rs) + reconciler := newReplicaSetReconciler(manager, om.NewEmptyMockedOmConnection) + client := manager.Client + + addKubernetesTlsResources(client, rs) + + secret := &corev1.Secret{} + + _ = client.Get(context.TODO(), types.NamespacedName{Name: fmt.Sprintf("%s-cert", rs.Name), Namespace: rs.Namespace}, secret) + + err := certs.VerifyAndEnsureCertificatesForStatefulSet(reconciler.SecretClient, reconciler.SecretClient, fmt.Sprintf("%s-cert", rs.Name), certs.ReplicaSetConfig(*rs), nil) + assert.Error(t, err) +} + +// createAgentCSRs creates all the agent CSRs needed for x509 at the specified condition type +func createAgentCSRs(numAgents int, client kubernetesClient.Client, conditionType certsv1.RequestConditionType) { + if numAgents != 1 && numAgents != 3 { + return + } + // create the secret the agent certs will exist in + certAuto, _ := ioutil.ReadFile("testdata/certificates/cert_auto") + + builder := secret.Builder(). + SetNamespace(mock.TestNamespace). + SetName(util.AgentSecretName). + SetField(util.AutomationAgentPemSecretKey, string(certAuto)) + + client.CreateSecret(builder.Build()) + + addCsrs(client, createCSR("mms-automation-agent", mock.TestNamespace, conditionType)) +} + +func addCsrs(client kubernetesClient.Client, csrs ...certsv1.CertificateSigningRequest) { + for _, csr := range csrs { + _ = client.Update(context.TODO(), &csr) + } +} + +// createCSR creates a CSR object which can be set to either CertificateApproved or CertificateDenied +func createCSR(name, ns string, conditionType certsv1.RequestConditionType) certsv1.CertificateSigningRequest { + return certsv1.CertificateSigningRequest{ + ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("%s.%s", name, ns)}, + Spec: certsv1.CertificateSigningRequestSpec{ + Request: createMockCSRBytes(), + }, + Status: certsv1.CertificateSigningRequestStatus{ + Conditions: []certsv1.CertificateSigningRequestCondition{ + {Type: conditionType}}}} +} + +// createMockCSRBytes creates a new Certificate Signing Request, signed with a +// fresh private key +func createMockCSRBytes() []byte { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + panic(err) + } + + template := x509.CertificateRequest{ + Subject: pkix.Name{ + Organization: []string{"MongoDB"}, + }, + DNSNames: []string{"somehost.com"}, + } + certRequestBytes, err := x509.CreateCertificateRequest(rand.Reader, &template, priv) + if err != nil { + panic(err) + } + + certRequestPemBytes := &bytes.Buffer{} + pemBlock := pem.Block{Type: "CERTIFICATE REQUEST", Bytes: certRequestBytes} + if err := pem.Encode(certRequestPemBytes, &pemBlock); err != nil { + panic(err) + } + + return certRequestPemBytes.Bytes() +} diff --git a/controllers/operator/automationconfig/automationconfig.go b/controllers/operator/automationconfig/automationconfig.go new file mode 100644 index 000000000..89938a88c --- /dev/null +++ b/controllers/operator/automationconfig/automationconfig.go @@ -0,0 +1,44 @@ +package automationconfig + +import ( + v1 "github.com/10gen/ops-manager-kubernetes/api/v1" + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/secret" + corev1 "k8s.io/api/core/v1" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// EnsureSecret fetches the existing Secret and applies the callback to it and pushes changes back. +// The callback is expected to update the data in Secret or return false if no update/create is needed +// Returns the final Secret (could be the initial one or the one after the update) +func EnsureSecret(secretGetUpdateCreator secret.GetUpdateCreator, nsName client.ObjectKey, callback func(*corev1.Secret) bool, owner v1.CustomResourceReadWriter) (corev1.Secret, error) { + existingSecret, err := secretGetUpdateCreator.GetSecret(nsName) + if err != nil { + if apiErrors.IsNotFound(err) { + newSecret := secret.Builder(). + SetName(nsName.Name). + SetNamespace(nsName.Namespace). + SetOwnerReferences(kube.BaseOwnerReference(owner)). + Build() + + if !callback(&newSecret) { + return corev1.Secret{}, nil + } + + if err := secretGetUpdateCreator.CreateSecret(newSecret); err != nil { + return corev1.Secret{}, err + } + return newSecret, nil + } + return corev1.Secret{}, err + } + // We are updating the existing Secret + if !callback(&existingSecret) { + return existingSecret, nil + } + if err := secretGetUpdateCreator.UpdateSecret(existingSecret); err != nil { + return existingSecret, err + } + return existingSecret, nil +} diff --git a/controllers/operator/automationconfig/automationconfig_test.go b/controllers/operator/automationconfig/automationconfig_test.go new file mode 100644 index 000000000..56e3ec9a9 --- /dev/null +++ b/controllers/operator/automationconfig/automationconfig_test.go @@ -0,0 +1,104 @@ +package automationconfig + +import ( + "context" + "reflect" + "testing" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/mock" + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/secret" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TestComputeSecret_CreateNew checks the "create" features of 'ensureAutomationConfigSecret' function when the secret is created +// if it doesn't exist (or the creation is skipped totally) +func TestEnsureAutomationConfigSecret_CreateNew(t *testing.T) { + client := mock.NewClient() + owner := mdbv1.MongoDB{ObjectMeta: metav1.ObjectMeta{Name: "test"}} + key := kube.ObjectKey("ns", "cfm") + testData := map[string][]byte{"foo": []byte("bar")} + + // Successful creation + createdSecret, err := EnsureSecret(client, key, func(secret *corev1.Secret) bool { + secret.Data = testData + return true + }, &owner) + + assert.NoError(t, err) + + s := &corev1.Secret{} + err = client.Get(context.TODO(), key, s) + assert.NoError(t, err) + assert.Equal(t, createdSecret, *s) + assert.Equal(t, key.Name, s.Name) + assert.Equal(t, key.Namespace, s.Namespace) + assert.Equal(t, "test", s.OwnerReferences[0].Name) + assert.Equal(t, testData, s.Data) + + // Creation skipped + key2 := kube.ObjectKey("ns", "cfm2") + _, err = EnsureSecret(client, key2, func(s *corev1.Secret) bool { + return false + }, &owner) + + assert.NoError(t, err) + err = client.Get(context.TODO(), key2, s) + assert.True(t, apiErrors.IsNotFound(err)) +} + +func TestEnsureAutomationConfig_UpdateExisting(t *testing.T) { + client := mock.NewClient() + err := client.CreateSecret(secret.Builder(). + SetNamespace(mock.TestNamespace). + SetName("secret-name"). + SetField(util.OmBaseUrl, "http://mycompany.com:8080"). + SetField(util.OmProjectName, "project-name"). + SetField(util.OmOrgId, "org-id"). + Build(), + ) + assert.NoError(t, err) + + owner := mdbv1.MongoDB{ObjectMeta: metav1.ObjectMeta{Name: "test"}} + + key := kube.ObjectKey(mock.TestNamespace, "secret-name") + + // Successful update (data is appended) + _, err = EnsureSecret(client, key, func(s *corev1.Secret) bool { + s.Data["foo"] = []byte("bla") + return true + }, &owner) + + assert.NoError(t, err) + + s := &corev1.Secret{} + err = client.Get(context.TODO(), key, s) + assert.NoError(t, err) + // We don't change the owner in case of update + assert.Empty(t, s.OwnerReferences) + // We added one key-value but the other must stay in the config map + assert.True(t, len(s.Data) > 1) + + currentSize := len(s.Data) + + // Update skipped + _, err = EnsureSecret(client, key, func(s *corev1.Secret) bool { + return false + }, &owner) + + assert.NoError(t, err) + + s = &corev1.Secret{} + err = client.Get(context.TODO(), key, s) + assert.NoError(t, err) + // The size of data must not change as there was no update + assert.Len(t, s.Data, currentSize) + + // The only operation in history is the first update + client.CheckNumberOfOperations(t, mock.HItem(reflect.ValueOf(client.Update), s), 1) +} diff --git a/controllers/operator/backup_snapshot_schedule_test.go b/controllers/operator/backup_snapshot_schedule_test.go new file mode 100644 index 000000000..5d79657de --- /dev/null +++ b/controllers/operator/backup_snapshot_schedule_test.go @@ -0,0 +1,147 @@ +package operator + +import ( + "context" + "reflect" + "testing" + + "k8s.io/utils/pointer" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/controllers/om/backup" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +func backupSnapshotScheduleTests(mdb backup.ConfigReaderUpdater, client *mock.MockedClient, reconciler reconcile.Reconciler, clusterID string) func(t *testing.T) { + return func(t *testing.T) { + t.Run("Backup schedule is not read and not updated if not specified in spec", testBackupScheduleNotReadAndNotUpdatedIfNotSpecifiedInSpec(mdb, client, reconciler, clusterID)) + t.Run("Backup schedule is updated if specified in spec", testBackupScheduleIsUpdatedIfSpecifiedInSpec(mdb, client, reconciler, clusterID)) + t.Run("Backup schedule is not updated if not changed", testBackupScheduleNotUpdatedIfNotChanged(mdb, client, reconciler, clusterID)) + } +} + +func testBackupScheduleNotReadAndNotUpdatedIfNotSpecifiedInSpec(mdb backup.ConfigReaderUpdater, client *mock.MockedClient, reconciler reconcile.Reconciler, clusterID string) func(t *testing.T) { + return func(t *testing.T) { + insertDefaultBackupSchedule(t, clusterID) + + mdb.GetBackupSpec().SnapshotSchedule = nil + + err := client.Update(context.TODO(), mdb) + assert.NoError(t, err) + + om.CurrMockedConnection.CleanHistory() + checkReconcile(t, reconciler, mdb) + om.CurrMockedConnection.CheckOperationsDidntHappen(t, reflect.ValueOf(om.CurrMockedConnection.UpdateSnapshotSchedule)) + om.CurrMockedConnection.CheckOperationsDidntHappen(t, reflect.ValueOf(om.CurrMockedConnection.ReadSnapshotSchedule)) + } +} + +func testBackupScheduleIsUpdatedIfSpecifiedInSpec(mdb backup.ConfigReaderUpdater, client *mock.MockedClient, reconciler reconcile.Reconciler, clusterID string) func(t *testing.T) { + return func(t *testing.T) { + insertDefaultBackupSchedule(t, clusterID) + + mdb.GetBackupSpec().SnapshotSchedule = &mdbv1.SnapshotSchedule{ + SnapshotIntervalHours: pointer.Int(1), + SnapshotRetentionDays: pointer.Int(2), + DailySnapshotRetentionDays: pointer.Int(3), + WeeklySnapshotRetentionWeeks: pointer.Int(4), + MonthlySnapshotRetentionMonths: pointer.Int(5), + PointInTimeWindowHours: pointer.Int(6), + ReferenceHourOfDay: pointer.Int(7), + ReferenceMinuteOfHour: pointer.Int(8), + FullIncrementalDayOfWeek: pointer.String("Sunday"), + ClusterCheckpointIntervalMin: pointer.Int(9), + } + + err := client.Update(context.TODO(), mdb) + require.NoError(t, err) + + checkReconcile(t, reconciler, mdb) + + snapshotSchedule, err := om.CurrMockedConnection.ReadSnapshotSchedule(clusterID) + require.NoError(t, err) + assertSnapshotScheduleEqual(t, mdb.GetBackupSpec().SnapshotSchedule, snapshotSchedule) + } +} + +func testBackupScheduleNotUpdatedIfNotChanged(mdb backup.ConfigReaderUpdater, kubeClient client.Client, reconciler reconcile.Reconciler, clusterID string) func(t *testing.T) { + return func(t *testing.T) { + insertDefaultBackupSchedule(t, clusterID) + + snapshotSchedule := &mdbv1.SnapshotSchedule{ + SnapshotIntervalHours: pointer.Int(11), + SnapshotRetentionDays: pointer.Int(12), + DailySnapshotRetentionDays: pointer.Int(13), + WeeklySnapshotRetentionWeeks: pointer.Int(14), + MonthlySnapshotRetentionMonths: pointer.Int(15), + PointInTimeWindowHours: pointer.Int(16), + ReferenceHourOfDay: pointer.Int(17), + ReferenceMinuteOfHour: pointer.Int(18), + FullIncrementalDayOfWeek: pointer.String("Thursday"), + ClusterCheckpointIntervalMin: pointer.Int(19), + } + + mdb.GetBackupSpec().SnapshotSchedule = snapshotSchedule + + err := kubeClient.Update(context.TODO(), mdb) + require.NoError(t, err) + + checkReconcile(t, reconciler, mdb) + + omSnapshotSchedule, err := om.CurrMockedConnection.ReadSnapshotSchedule(clusterID) + require.NoError(t, err) + + assertSnapshotScheduleEqual(t, mdb.GetBackupSpec().SnapshotSchedule, omSnapshotSchedule) + + om.CurrMockedConnection.CleanHistory() + checkReconcile(t, reconciler, mdb) + + om.CurrMockedConnection.CheckOperationsDidntHappen(t, reflect.ValueOf(om.CurrMockedConnection.UpdateSnapshotSchedule)) + + mdb.GetBackupSpec().SnapshotSchedule.FullIncrementalDayOfWeek = pointer.String("Monday") + err = kubeClient.Update(context.TODO(), mdb) + require.NoError(t, err) + + checkReconcile(t, reconciler, mdb) + + omSnapshotSchedule, err = om.CurrMockedConnection.ReadSnapshotSchedule(clusterID) + assert.NoError(t, err) + require.NotNil(t, omSnapshotSchedule) + require.NotNil(t, omSnapshotSchedule.FullIncrementalDayOfWeek) + assert.Equal(t, "Monday", *omSnapshotSchedule.FullIncrementalDayOfWeek) + } +} + +func insertDefaultBackupSchedule(t *testing.T, clusterID string) { + // insert default backup schedule + err := om.CurrMockedConnection.UpdateSnapshotSchedule(clusterID, &backup.SnapshotSchedule{ + GroupID: om.TestGroupID, + ClusterID: clusterID, + }) + assert.NoError(t, err) +} + +func assertSnapshotScheduleEqual(t *testing.T, expected *mdbv1.SnapshotSchedule, actual *backup.SnapshotSchedule) { + assert.Equal(t, expected.SnapshotIntervalHours, actual.SnapshotIntervalHours) + assert.Equal(t, expected.SnapshotRetentionDays, actual.SnapshotRetentionDays) + assert.Equal(t, expected.DailySnapshotRetentionDays, actual.DailySnapshotRetentionDays) + assert.Equal(t, expected.WeeklySnapshotRetentionWeeks, actual.WeeklySnapshotRetentionWeeks) + assert.Equal(t, expected.MonthlySnapshotRetentionMonths, actual.MonthlySnapshotRetentionMonths) + assert.Equal(t, expected.PointInTimeWindowHours, actual.PointInTimeWindowHours) + assert.Equal(t, expected.ReferenceHourOfDay, actual.ReferenceHourOfDay) + assert.Equal(t, expected.ReferenceMinuteOfHour, actual.ReferenceMinuteOfHour) + assert.Equal(t, expected.FullIncrementalDayOfWeek, actual.FullIncrementalDayOfWeek) + assert.Equal(t, expected.ClusterCheckpointIntervalMin, actual.ClusterCheckpointIntervalMin) +} + +func checkReconcile(t *testing.T, reconciler reconcile.Reconciler, resource metav1.Object) { + result, e := reconciler.Reconcile(context.TODO(), requestFromObject(resource)) + require.NoError(t, e) + require.Equal(t, reconcile.Result{}, result) +} diff --git a/controllers/operator/certs/cert_configurations.go b/controllers/operator/certs/cert_configurations.go new file mode 100644 index 000000000..4eb7786b2 --- /dev/null +++ b/controllers/operator/certs/cert_configurations.go @@ -0,0 +1,291 @@ +package certs + +import ( + "fmt" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/api/v1/mdbmulti" + omv1 "github.com/10gen/ops-manager-kubernetes/api/v1/om" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/secrets" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/util/scale" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type clustermode string + +const ( + single = "single" + multi = "multi" +) + +// X509CertConfigurator provides the methods required for ensuring the existence of X.509 certificates +// for encrypted communications in MongoDB resource +type X509CertConfigurator interface { + GetName() string + GetNamespace() string + GetDbCommonSpec() mdbv1.DbCommonSpec + GetCertOptions() []Options + GetSecretReadClient() secrets.SecretClient + GetSecretWriteClient() secrets.SecretClient +} + +type ReplicaSetX509CertConfigurator struct { + *mdbv1.MongoDB + SecretClient secrets.SecretClient +} + +var _ X509CertConfigurator = ReplicaSetX509CertConfigurator{} + +func (rs ReplicaSetX509CertConfigurator) GetCertOptions() []Options { + return []Options{ReplicaSetConfig(*rs.MongoDB)} +} + +func (rs ReplicaSetX509CertConfigurator) GetSecretReadClient() secrets.SecretClient { + return rs.SecretClient +} + +func (rs ReplicaSetX509CertConfigurator) GetSecretWriteClient() secrets.SecretClient { + return rs.SecretClient +} + +func (rs ReplicaSetX509CertConfigurator) GetDbCommonSpec() mdbv1.DbCommonSpec { + return rs.Spec.DbCommonSpec +} + +type ShardedSetX509CertConfigurator struct { + *mdbv1.MongoDB + MongodsPerShardScaler scale.ReplicaSetScaler + MongosScaler scale.ReplicaSetScaler + ConfigSrvScaler scale.ReplicaSetScaler + SecretClient secrets.SecretClient +} + +var _ X509CertConfigurator = ShardedSetX509CertConfigurator{} + +func (sc ShardedSetX509CertConfigurator) GetCertOptions() []Options { + certOptions := make([]Options, 0) + for i := 0; i < sc.Spec.ShardCount; i++ { + certOptions = append(certOptions, ShardConfig(*sc.MongoDB, i, sc.MongodsPerShardScaler)) + } + certOptions = append(certOptions, MongosConfig(*sc.MongoDB, sc.MongosScaler)) + certOptions = append(certOptions, ConfigSrvConfig(*sc.MongoDB, sc.ConfigSrvScaler)) + return certOptions +} + +func (sc ShardedSetX509CertConfigurator) GetSecretReadClient() secrets.SecretClient { + return sc.SecretClient +} + +func (sc ShardedSetX509CertConfigurator) GetSecretWriteClient() secrets.SecretClient { + return sc.SecretClient +} + +func (sc ShardedSetX509CertConfigurator) GetDbCommonSpec() mdbv1.DbCommonSpec { + return sc.Spec.DbCommonSpec +} + +type StandaloneX509CertConfigurator struct { + *mdbv1.MongoDB + SecretClient secrets.SecretClient +} + +var _ X509CertConfigurator = StandaloneX509CertConfigurator{} + +func (s StandaloneX509CertConfigurator) GetCertOptions() []Options { + return []Options{StandaloneConfig(*s.MongoDB)} +} + +func (s StandaloneX509CertConfigurator) GetSecretReadClient() secrets.SecretClient { + return s.SecretClient +} + +func (s StandaloneX509CertConfigurator) GetSecretWriteClient() secrets.SecretClient { + return s.SecretClient +} + +func (s StandaloneX509CertConfigurator) GetDbCommonSpec() mdbv1.DbCommonSpec { + return s.Spec.DbCommonSpec +} + +type MongoDBMultiX509CertConfigurator struct { + *mdbmulti.MongoDBMultiCluster + ClusterNum int + ClusterName string + Replicas int + SecretReadClient secrets.SecretClient + SecretWriteClient secrets.SecretClient +} + +var _ X509CertConfigurator = MongoDBMultiX509CertConfigurator{} + +func (mdbm MongoDBMultiX509CertConfigurator) GetCertOptions() []Options { + return []Options{MultiReplicaSetConfig(*mdbm.MongoDBMultiCluster, mdbm.ClusterNum, mdbm.ClusterName, mdbm.Replicas)} +} + +func (mdbm MongoDBMultiX509CertConfigurator) GetSecretReadClient() secrets.SecretClient { + return mdbm.SecretReadClient +} + +func (mdbm MongoDBMultiX509CertConfigurator) GetSecretWriteClient() secrets.SecretClient { + return mdbm.SecretWriteClient +} + +func (mdbm MongoDBMultiX509CertConfigurator) GetDbCommonSpec() mdbv1.DbCommonSpec { + return mdbm.Spec.DbCommonSpec +} + +type Options struct { + // CertSecretName is the name of the secret which contains the certs. + CertSecretName string + + // InternalClusterSecretName is the name of the secret which contains the certs of internal cluster auth. + InternalClusterSecretName string + // ResourceName is the name of the resource. + ResourceName string + // Replicas is the number of replicas. + Replicas int + // Namespace is the namepsace the resource is in. + Namespace string + // ServiceName is the name of the service which is created for the resource. + ServiceName string + // ClusterDomain is the cluster domain for the resource + ClusterDomain string + additionalCertificateDomains []string + // External domain for external access (if enabled) + ExternalDomain *string + + // horizons is an array of MongoDBHorizonConfig which is used to determine any + // additional cert domains required. + horizons []mdbv1.MongoDBHorizonConfig + + ClusterMode clustermode + + OwnerReference []metav1.OwnerReference +} + +// StandaloneConfig returns a function which provides all of the configuration options required for the given Standalone. +func StandaloneConfig(mdb mdbv1.MongoDB) Options { + return Options{ + ResourceName: mdb.Name, + CertSecretName: GetCertNameWithPrefixOrDefault(*mdb.GetSecurity(), mdb.Name), + Namespace: mdb.Namespace, + ServiceName: mdb.ServiceName(), + Replicas: 1, + ClusterDomain: mdb.Spec.GetClusterDomain(), + additionalCertificateDomains: mdb.Spec.Security.TLSConfig.AdditionalCertificateDomains, + OwnerReference: mdb.GetOwnerReferences(), + ExternalDomain: mdb.Spec.DbCommonSpec.GetExternalDomain(), + } +} + +// ReplicaSetConfig returns a struct which provides all of the configuration options required for the given Replica Set. +func ReplicaSetConfig(mdb mdbv1.MongoDB) Options { + return Options{ + ResourceName: mdb.Name, + CertSecretName: mdb.GetSecurity().MemberCertificateSecretName(mdb.Name), + InternalClusterSecretName: mdb.GetSecurity().InternalClusterAuthSecretName(mdb.Name), + Namespace: mdb.Namespace, + Replicas: scale.ReplicasThisReconciliation(&mdb), + ServiceName: mdb.ServiceName(), + ClusterDomain: mdb.Spec.GetClusterDomain(), + additionalCertificateDomains: mdb.Spec.Security.TLSConfig.AdditionalCertificateDomains, + horizons: mdb.Spec.Connectivity.ReplicaSetHorizons, + OwnerReference: mdb.GetOwnerReferences(), + ExternalDomain: mdb.Spec.DbCommonSpec.GetExternalDomain(), + } +} + +func AppDBReplicaSetConfig(om omv1.MongoDBOpsManager) Options { + mdb := om.Spec.AppDB + opts := Options{ + ResourceName: mdb.Name(), + CertSecretName: mdb.GetSecurity().MemberCertificateSecretName(mdb.Name()), + InternalClusterSecretName: mdb.GetSecurity().InternalClusterAuthSecretName(mdb.Name()), + Namespace: mdb.Namespace, + Replicas: scale.ReplicasThisReconciliation(&om), + ServiceName: mdb.ServiceName(), + ClusterDomain: mdb.ClusterDomain, + OwnerReference: om.GetOwnerReferences(), + } + + if mdb.GetSecurity().TLSConfig != nil { + opts.additionalCertificateDomains = append(opts.additionalCertificateDomains, mdb.GetSecurity().TLSConfig.AdditionalCertificateDomains...) + } + + return opts +} + +// ShardConfig returns a struct which provides all the configuration options required for the given shard. +func ShardConfig(mdb mdbv1.MongoDB, shardNum int, scaler scale.ReplicaSetScaler) Options { + return Options{ + ResourceName: mdb.ShardRsName(shardNum), + CertSecretName: mdb.GetSecurity().MemberCertificateSecretName(mdb.ShardRsName(shardNum)), + InternalClusterSecretName: mdb.GetSecurity().InternalClusterAuthSecretName(mdb.ShardRsName(shardNum)), + Namespace: mdb.Namespace, + Replicas: scale.ReplicasThisReconciliation(scaler), + ServiceName: mdb.ShardServiceName(), + ClusterDomain: mdb.Spec.GetClusterDomain(), + additionalCertificateDomains: mdb.Spec.Security.TLSConfig.AdditionalCertificateDomains, + OwnerReference: mdb.GetOwnerReferences(), + ExternalDomain: mdb.Spec.DbCommonSpec.GetExternalDomain(), + } +} + +// MultiReplicaSetConfig returns a struct which provides all of the configuration required for a given MongoDB Multi Replicaset. +func MultiReplicaSetConfig(mdbm mdbmulti.MongoDBMultiCluster, clusterNum int, clusterName string, replicas int) Options { + return Options{ + ResourceName: mdbm.MultiStatefulsetName(clusterNum), + CertSecretName: mdbm.Spec.GetSecurity().MemberCertificateSecretName(mdbm.Name), + InternalClusterSecretName: mdbm.Spec.GetSecurity().InternalClusterAuthSecretName(mdbm.Name), + Namespace: mdbm.Namespace, + Replicas: replicas, + ClusterDomain: mdbm.Spec.GetClusterDomain(), + ClusterMode: multi, + OwnerReference: mdbm.GetOwnerReferences(), + ExternalDomain: mdbm.ExternalMemberClusterDomain(clusterName), + } +} + +// MongosConfig returns a struct which provides all of the configuration options required for the given Mongos. +func MongosConfig(mdb mdbv1.MongoDB, scaler scale.ReplicaSetScaler) Options { + return Options{ + ResourceName: mdb.MongosRsName(), + CertSecretName: mdb.GetSecurity().MemberCertificateSecretName(mdb.MongosRsName()), + InternalClusterSecretName: mdb.GetSecurity().InternalClusterAuthSecretName(mdb.MongosRsName()), + Namespace: mdb.Namespace, + Replicas: scale.ReplicasThisReconciliation(scaler), + ServiceName: mdb.ServiceName(), + ClusterDomain: mdb.Spec.GetClusterDomain(), + additionalCertificateDomains: mdb.Spec.Security.TLSConfig.AdditionalCertificateDomains, + OwnerReference: mdb.GetOwnerReferences(), + ExternalDomain: mdb.Spec.DbCommonSpec.GetExternalDomain(), + } +} + +// ConfigSrvConfig returns a struct which provides all of the configuration options required for the given ConfigServer. +func ConfigSrvConfig(mdb mdbv1.MongoDB, scaler scale.ReplicaSetScaler) Options { + return Options{ + ResourceName: mdb.ConfigRsName(), + CertSecretName: mdb.GetSecurity().MemberCertificateSecretName(mdb.ConfigRsName()), + InternalClusterSecretName: mdb.GetSecurity().InternalClusterAuthSecretName(mdb.ConfigRsName()), + Namespace: mdb.Namespace, + Replicas: scale.ReplicasThisReconciliation(scaler), + ServiceName: mdb.ConfigSrvServiceName(), + ClusterDomain: mdb.Spec.GetClusterDomain(), + additionalCertificateDomains: mdb.Spec.Security.TLSConfig.AdditionalCertificateDomains, + horizons: mdb.Spec.Connectivity.ReplicaSetHorizons, + OwnerReference: mdb.GetOwnerReferences(), + ExternalDomain: mdb.Spec.DbCommonSpec.GetExternalDomain(), + } +} + +// GetCertNameWithPrefixOrDefault returns the name of the cert that will store certificates for the given resource. +// this takes into account the tlsConfig.prefix option. +func GetCertNameWithPrefixOrDefault(ms mdbv1.Security, defaultName string) string { + if ms.CertificatesSecretsPrefix != "" { + return fmt.Sprintf("%s-%s-cert", ms.CertificatesSecretsPrefix, defaultName) + } + + return defaultName + "-cert" +} diff --git a/controllers/operator/certs/certificate_test.go b/controllers/operator/certs/certificate_test.go new file mode 100644 index 000000000..dd22d4aab --- /dev/null +++ b/controllers/operator/certs/certificate_test.go @@ -0,0 +1,73 @@ +package certs + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestVerifyTLSSecretForStatefulSet(t *testing.T) { + + crt := `-----BEGIN CERTIFICATE----- +MIIFWzCCA0OgAwIBAgIRAJ/vHVZs6TGyhP/AIR+TAvMwDQYJKoZIhvcNAQELBQAw +cTELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE5ldyBZb3JrMREwDwYDVQQHDAhOZXcg +WW9yazEQMA4GA1UECgwHTW9uZ29EQjEQMA4GA1UECwwHbW9uZ29kYjEYMBYGA1UE +AwwPd3d3Lm1vbmdvZGIuY29tMB4XDTIyMDQxMTA5MzkwM1oXDTIyMDQyMTA5Mzkw +M1owADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANV64qjt1rmfuYAZ +Ly9rOogtQTiFNJqkfVCzaQ8ImztqxeXAwoNd97Jwm82xgpUR7Mrku0raexIhKD8R +zgOr870L7si4MXmZdkuxvQf+SASOsUuaBtc7epzOzfhV2F/2Rgg/RzZEPNLtF9KL +GWrS73q7cz2ZGbTmc6q6DLikQKVq1d/ufCo1ly0i/kc+Koxu4GdamHxyCysFRqIb +RoFdiRSHEYha65U2I+CERBie+LsxCJOK/uYWzP32XRGsFnbmPFscy5A5WMaeIpX/ +Bj8FShEBciwjiFrDktG3FeGd0JkVbUaa10i8xhAw0V66E/bPipKJiSNNxdMVeyhB +g5SQiCUCAwEAAaOCAV0wggFZMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD +AjAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFM515VEbUh5N/Tl3GYUuanyfd4BO +MIIBBwYDVR0RAQH/BIH8MIH5ghFyZXBsaWNhLXNldC10bHMtMIIRcmVwbGljYS1z +ZXQtdGxzLTGCEXJlcGxpY2Etc2V0LXRscy0ygj5yZXBsaWNhLXNldC10bHMtMC5y +ZXBsaWNhLXNldC10bHMtc3ZjLnJhajI0Mi5zdmMuY2x1c3Rlci5sb2NhbII+cmVw +bGljYS1zZXQtdGxzLTEucmVwbGljYS1zZXQtdGxzLXN2Yy5yYWoyNDIuc3ZjLmNs +dXN0ZXIubG9jYWyCPnJlcGxpY2Etc2V0LXRscy0yLnJlcGxpY2Etc2V0LXRscy1z +dmMucmFqMjQyLnN2Yy5jbHVzdGVyLmxvY2FsMA0GCSqGSIb3DQEBCwUAA4ICAQCS +/0L4gwWJkfKoA/11EMY2PD+OdUtp0z3DXQH1M34iX8ZGWkej9W0FLDHeipVVKpUY +zoUJSlN2NGd+evSGiVG62z76fUu3ztsdVtL0/P4IILNowmj7KZ6AVfOWX/zYVKsy +EWoQp4dSged5HjvHGNgb7r78W5Ye7Bi6C6aLGBt1OSEA6aDr5HLoChxMFfVFw/gB +UFRZm6bnGRTIsb3NFHjw+CxzLGvf+I+RWzXoc9g5bNKCtlOBKSNao15fFp3YSYLK +9hsGe47sJH7WssPuxAELjJA8UOle3+pSl2mg2OhoYvF3YLTt2vMqOWh0emgEXiXk +eDzw0h6ZuucAXG8YkK0PAvNDHSnemyVtFmo5UUn8rPCRm3Ztxy5lAQlGSk5q1eKG +XxBrBf/eLEE6t6gkV17znQOyRgYgCsD90InLtoBK39dBgZcuDE+KJuY4/6a+GL0R +GgcqDSgjU2OgHXGDJM9GUQlMWOcIaxxEeuC3BYErZPlLg/URSWpfzxYWSxFUGk2p +uD7tgoEguEmih1e1Fc8nIlByx3zj+ifNkuwHytIvip3uvaiQZTEda9i9TMZ6sDeN +1pAwMVMKOGqYBR6ibZJ20vGiKMwBLCQOEqckZg5j2ULHx5LYbbF24uKpMTX8OAtQ +WyU44tyEDe5JUgwo3G/3/Hp6WJARpV1MVy3y5hHNaA== +-----END CERTIFICATE-----` + key := `-----BEGIN RSA PRIVATE KEY----- +MIIEpgIBAAKCAQEA1XriqO3WuZ+5gBkvL2s6iC1BOIU0mqR9ULNpDwibO2rF5cDC +g133snCbzbGClRHsyuS7Stp7EiEoPxHOA6vzvQvuyLgxeZl2S7G9B/5IBI6xS5oG +1zt6nM7N+FXYX/ZGCD9HNkQ80u0X0osZatLvertzPZkZtOZzqroMuKRApWrV3+58 +KjWXLSL+Rz4qjG7gZ1qYfHILKwVGohtGgV2JFIcRiFrrlTYj4IREGJ74uzEIk4r+ +5hbM/fZdEawWduY8WxzLkDlYxp4ilf8GPwVKEQFyLCOIWsOS0bcV4Z3QmRVtRprX +SLzGEDDRXroT9s+KkomJI03F0xV7KEGDlJCIJQIDAQABAoIBAQCGltDru/cSVFb5 +IeeTt8DRNebWoXSGwomXJWVo6v4jOa/Gp/56H/YX89LmnbE8Fm75g7do+9F3npvn +F2yQ+AnU9/71YNsgVNY15rrMnU3+QZAZn+QMMh2dWuyUUlr2NSf17x8QYXkPahcI +0FWX+aCt+hwvi6SfXmMyEdYPWs6++jND8MpjrKJHoNObD22NloVw2JaT9msmYuI9 +j/JnTXbpxWSWbwmyqQOJub4kaTPANCRbTgmTersLk1Iu+Q+jCTtN8teRTmx+3kzy +mq8CdPBrME65yRorPZPGTdGwe2pWiLeug288DGKDvqusSv79piVY0wbK3mZYjX70 +MbEwzIfBAoGBAO56pfG9HwwP3ZS6OU22YK6oHY/moLsWYIkcA1qi9v+wQie/Rf+6 +tLEFNtQ94hw8kncUDPNv+FtnvpywL/5ZBF0JAAJqxr1tBQKtqvPFzA4qY53kE0Lz +fLLEA1rX4JeRQlDCRei8zGhgAYkWfSDKd6/DHqc0iTKMVMCh5D1BSqU5AoGBAOUq +DPv0uKCGi0s3eQTBgnJi0ivPt8RCmqZMi5Xss4y60sI7dZm5J7tIEZ9/4sqRSC6w +qQBWLwIVnVC3qUhubEXnfk+07mBVLohNTFeWW1Sb7WJp5NnUOeg+w2f5gvbfItzt +cY7l+rWkxuklKlq6AqfZfVk6aWoa8v4MkyUsqYZNAoGBAOAexcu9F+uHEZAPv4Do +UE50UmwFq7KHoivY9tH8a7L6XAHswYVHWz8uDkxC6DfvORrN7inuZfLJOhsZfdFE +qVQh/C9JWAN37IiK3CmDD3WUotAlI3D9UYjTq+95CGqJKlCpc3f5zwScjXTffLMP +dJHrBujO981YkuICg3SJ4vQJAoGBAONXrDnotaDK2TVteul07+x6jPZZw304diO0 +nGXHxPg//wYh5rDyNrBc9t69CEjdiDaJm59x4IC44LBLA+2PXmqbFXwNis6WsusV +hD8AMurlJcMUOqy/FhOI8GId7gbrprJ1/Mo+7VF2fr6c2D/ZePj7kpcKk7lnstjF +sNSYUjWhAoGBAKjzEsMga2Lonk5sHkVPkYm8Kl4PrfK4lj1U78to0tgcguLjGyRW +c4IFWO5/ibu3HV3eLMiJBmSR8iR5Iue9cCm782/tncuWLsCoDGUmCG1D2Q4iBx2J +EHE1zXjECRf87xR2aKbPUR+44bG/ILCbUypquP48GC+S6OqVF7utkXgF +-----END RSA PRIVATE KEY-----` + secretData := map[string][]byte{"tls.crt": []byte(crt), "tls.key": []byte(key)} + + _, err := VerifyTLSSecretForStatefulSet(secretData, Options{}) + assert.NoError(t, err) +} diff --git a/controllers/operator/certs/certificates.go b/controllers/operator/certs/certificates.go new file mode 100644 index 000000000..1f2d874d4 --- /dev/null +++ b/controllers/operator/certs/certificates.go @@ -0,0 +1,403 @@ +package certs + +import ( + "fmt" + "net/url" + "strings" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/hashicorp/go-multierror" + mdbcv1 "github.com/mongodb/mongodb-kubernetes-operator/api/v1" + "golang.org/x/xerrors" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/secrets" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/workflow" + "go.uber.org/zap" + + enterprisepem "github.com/10gen/ops-manager-kubernetes/controllers/operator/pem" + "github.com/10gen/ops-manager-kubernetes/pkg/dns" + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "github.com/10gen/ops-manager-kubernetes/pkg/multicluster" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/vault" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/secret" + corev1 "k8s.io/api/core/v1" + + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +type certDestination string + +const ( + OperatorGeneratedCertSuffix = "-pem" + CertHashAnnotationKey = "certHash" + + Unused = "unused" + Database = "database" + OpsManager = "opsmanager" + AppDB = "appdb" +) + +// CreatePEMSecretClient creates a PEM secret from the original secretName. +func CreatePEMSecretClient(secretClient secrets.SecretClient, secretNamespacedName types.NamespacedName, data map[string]string, ownerReferences []metav1.OwnerReference, podType certDestination) error { + operatorGeneratedSecret := secretNamespacedName + operatorGeneratedSecret.Name = fmt.Sprintf("%s%s", secretNamespacedName.Name, OperatorGeneratedCertSuffix) + + secretBuilder := secret.Builder(). + SetName(operatorGeneratedSecret.Name). + SetNamespace(operatorGeneratedSecret.Namespace). + SetStringMapToData(data). + SetOwnerReferences(ownerReferences) + + var path string + if vault.IsVaultSecretBackend() && podType != Unused { + switch podType { + case Database: + path = secretClient.VaultClient.DatabaseSecretPath() + case OpsManager: + path = secretClient.VaultClient.OpsManagerSecretPath() + case AppDB: + path = secretClient.VaultClient.AppDBSecretPath() + default: + return xerrors.Errorf("unexpected pod type got: %s", podType) + } + } + + return secretClient.PutSecretIfChanged(secretBuilder.Build(), path) +} + +// VerifyTLSSecretForStatefulSet verifies a `Secret`'s `StringData` is a valid +// certificate, considering the amount of members for a resource named on +// `opts`. +func VerifyTLSSecretForStatefulSet(secretData map[string][]byte, opts Options) (string, error) { + crt, key := secretData["tls.crt"], secretData["tls.key"] + + // add a line break to the end of certificate when performing concatenation + crtString := string(crt) + if !strings.HasSuffix(crtString, "\n") { + crtString = crtString + "\n" + } + crt = []byte(crtString) + + data := append(crt, key...) + + var additionalDomains []string + for i := range getPodNames(opts) { + additionalDomains = append(additionalDomains, GetAdditionalCertDomainsForMember(opts, i)...) + } + if opts.ExternalDomain != nil { + additionalDomains = append(additionalDomains, "*."+*opts.ExternalDomain) + } + + if err := validatePemData(data, additionalDomains); err != nil { + return "", err + } + return string(data), nil +} + +// VerifyAndEnsureCertificatesForStatefulSet ensures that the provided certificates are correct. +// If the secret is of type kubernetes.io/tls, it creates a new secret containing the concatenation fo the tls.crt and tls.key fields +func VerifyAndEnsureCertificatesForStatefulSet(secretReadClient, secretWriteClient secrets.SecretClient, secretName string, opts Options, log *zap.SugaredLogger) error { + + needToCreatePEM := false + var err error + var secretData map[string][]byte + var s corev1.Secret + var databaseSecretPath string + + if vault.IsVaultSecretBackend() { + needToCreatePEM = true + databaseSecretPath = secretReadClient.VaultClient.DatabaseSecretPath() + secretData, err = secretReadClient.VaultClient.ReadSecretBytes(fmt.Sprintf("%s/%s/%s", databaseSecretPath, opts.Namespace, secretName)) + if err != nil { + return err + } + + } else { + s, err = secretReadClient.KubeClient.GetSecret(kube.ObjectKey(opts.Namespace, secretName)) + if err != nil { + return err + } + + // SecretTypeTLS is kubernetes.io/tls + // This is the standard way in K8S to have secrets that hold TLS certs + // And it is the one generated by cert manager + // These type of secrets contain tls.crt and tls.key entries + if s.Type == corev1.SecretTypeTLS { + needToCreatePEM = true + secretData = s.Data + } + } + + if needToCreatePEM { + data, err := VerifyTLSSecretForStatefulSet(secretData, opts) + if err != nil { + return err + } + + secretHash := enterprisepem.ReadHashFromSecret(secretReadClient, opts.Namespace, secretName, databaseSecretPath, log) + return CreatePEMSecretClient(secretWriteClient, kube.ObjectKey(opts.Namespace, secretName), map[string]string{secretHash: data}, opts.OwnerReference, Database) + } + var errs error + + if opts.ClusterMode == multi { + // get the pod names and get the service FQDN for each of the service hostnames + mdbmName := multicluster.GetRsNamefromMultiStsName(opts.ResourceName) + clusterNum := multicluster.MustGetClusterNumFromMultiStsName(opts.ResourceName) + externalDomain := opts.ExternalDomain + + for podNum := 0; podNum < opts.Replicas; podNum++ { + podName, serviceFQDN := dns.GetMultiPodName(mdbmName, clusterNum, podNum), dns.GetMultiServiceFQDN(mdbmName, opts.Namespace, clusterNum, podNum) + pem := fmt.Sprintf("%s-pem", podName) + additionalDomains := []string{serviceFQDN} + if externalDomain != nil { + additionalDomains = append(additionalDomains, "*."+*externalDomain) + } + if err := validatePemSecret(s, pem, additionalDomains); err != nil { + errs = multierror.Append(errs, err) + } + } + return errs + } + + for i, pod := range getPodNames(opts) { + pem := fmt.Sprintf("%s-pem", pod) + additionalDomains := GetAdditionalCertDomainsForMember(opts, i) + if err := validatePemSecret(s, pem, additionalDomains); err != nil { + errs = multierror.Append(errs, err) + } + } + return errs +} + +// getPodNames returns the pod names based on the Cert Options provided. +func getPodNames(opts Options) []string { + _, podNames := dns.GetDNSNames(opts.ResourceName, opts.ServiceName, opts.Namespace, opts.ClusterDomain, opts.Replicas, nil) + return podNames +} + +func GetDNSNames(opts Options) (hostnames, podNames []string) { + return dns.GetDNSNames(opts.ResourceName, opts.ServiceName, opts.Namespace, opts.ClusterDomain, opts.Replicas, nil) +} + +// GetAdditionalCertDomainsForMember gets any additional domains that the +// certificate for the given member of the stateful set should be signed for. +func GetAdditionalCertDomainsForMember(opts Options, member int) (hostnames []string) { + _, podNames := GetDNSNames(opts) + for _, certDomain := range opts.additionalCertificateDomains { + hostnames = append(hostnames, podNames[member]+"."+certDomain) + } + if len(opts.horizons) > 0 { + //at this point len(ss.ReplicaSetHorizons) should be equal to the number + //of members in the replica set + for _, externalHost := range opts.horizons[member] { + //need to use the URL struct directly instead of url.Parse as + //Parse expects the URL to have a scheme. + hostURL := url.URL{Host: externalHost} + hostnames = append(hostnames, hostURL.Hostname()) + } + } + return hostnames +} + +func validatePemData(data []byte, additionalDomains []string) error { + pemFile := enterprisepem.NewFileFromData(data) + if !pemFile.IsComplete() { + return xerrors.Errorf("the certificate is not complete\n") + } + certs, err := pemFile.ParseCertificate() + if err != nil { + return xerrors.Errorf("can't parse certificate: %w\n", err) + } + + var errs error + // in case of using an intermediate certificate authority, the certificate + // data might contain all the certificate chain excluding the root-ca (in case of cert-manager). + // We need to iterate through the certificates in the chain and find the one at the bottom of the chain + // containing the additionalDomains which we're validating for. + for _, cert := range certs { + var err error + for _, domain := range additionalDomains { + if !stringutil.CheckCertificateAddresses(cert.DNSNames, domain) { + err = xerrors.Errorf("domain %s is not contained in the list of DNSNames %v\n", domain, cert.DNSNames) + errs = multierror.Append(errs, err) + } + } + if err == nil { + return nil + } + } + + return errs +} + +// validatePemSecret returns true if the given Secret contains a parsable certificate and contains all required domains. +func validatePemSecret(secret corev1.Secret, key string, additionalDomains []string) error { + data, ok := secret.Data[key] + if !ok { + return xerrors.Errorf("the secret %s does not contain the expected key %s\n", secret.Name, key) + } + + return validatePemData(data, additionalDomains) +} + +// ValidateCertificates verifies the Secret containing the certificates and the keys is valid. +func ValidateCertificates(secretGetter secret.Getter, name, namespace string) error { + byteData, err := secret.ReadByteData(secretGetter, kube.ObjectKey(namespace, name)) + if err == nil { + // Validate that the secret contains the keys, if it contains the certs. + for _, value := range byteData { + pemFile := enterprisepem.NewFileFromData(value) + if !pemFile.IsValid() { + return xerrors.Errorf("The Secret %s containing certificates is not valid. Entries must contain a certificate and a private key.", name) + } + } + } + return nil +} + +// VerifyAndEnsureClientCertificatesForAgentsAndTLSType ensures that agent certs are present and correct, and returns whether or not they are of the kubernetes.io/tls type. +// If the secret is of type kubernetes.io/tls, it creates a new secret containing the concatenation fo the tls.crt and tls.key fields +func VerifyAndEnsureClientCertificatesForAgentsAndTLSType(secretReadClient, secretWriteClient secrets.SecretClient, secret types.NamespacedName, log *zap.SugaredLogger) error { + needToCreatePEM := false + var secretData map[string][]byte + var s corev1.Secret + var err error + + if vault.IsVaultSecretBackend() { + needToCreatePEM = true + secretData, err = secretReadClient.VaultClient.ReadSecretBytes(fmt.Sprintf("%s/%s/%s", secretReadClient.VaultClient.DatabaseSecretPath(), secret.Namespace, secret.Name)) + + if err != nil { + return err + } + } else { + s, err = secretReadClient.KubeClient.GetSecret(secret) + if err != nil { + return err + } + + if s.Type == corev1.SecretTypeTLS { + needToCreatePEM = true + secretData = s.Data + } + } + if needToCreatePEM { + data, err := VerifyTLSSecretForStatefulSet(secretData, Options{Replicas: 0}) + if err != nil { + return err + } + dataMap := map[string]string{ + util.AutomationAgentPemSecretKey: data, + } + return CreatePEMSecretClient(secretWriteClient, secret, dataMap, []metav1.OwnerReference{}, Database) + } + + return validatePemSecret(s, util.AutomationAgentPemSecretKey, nil) +} + +// EnsureSSLCertsForStatefulSet contains logic to ensure that all of the +// required SSL certs for a StatefulSet object exist. +func EnsureSSLCertsForStatefulSet(secretReadClient, secretWriteClient secrets.SecretClient, ms mdbv1.Security, opts Options, log *zap.SugaredLogger) workflow.Status { + if !ms.IsTLSEnabled() { + // if there's no SSL certs to generate, return + return workflow.OK() + } + + secretName := opts.CertSecretName + return ValidateSelfManagedSSLCertsForStatefulSet(secretReadClient, secretWriteClient, secretName, opts, log) + +} + +// EnsureTLSCertsForPrometheus creates a new Secret with a Certificate in +// PEM-format. Returns the hash for the certificate in order to be used in +// AutomationConfig. +// +// For Prometheus we *only accept* certificates of type `corev1.SecretTypeTLS` +// so they always need to be concatenated into PEM-format. +func EnsureTLSCertsForPrometheus(secretClient secrets.SecretClient, namespace string, prom *mdbcv1.Prometheus, podType certDestination, log *zap.SugaredLogger) (string, error) { + if prom == nil || prom.TLSSecretRef.Name == "" { + return "", nil + } + + var secretData map[string][]byte + var err error + + var secretPath string + if vault.IsVaultSecretBackend() { + // TODO: This is calculated twice, can this be done better? + // This "calculation" is used in ReadHashFromSecret but calculated again in `CreatePEMSecretClient` + if podType == Database { + secretPath = secretClient.VaultClient.DatabaseSecretPath() + } else if podType == AppDB { + secretPath = secretClient.VaultClient.AppDBSecretPath() + } + + secretData, err = secretClient.VaultClient.ReadSecretBytes(fmt.Sprintf("%s/%s/%s", secretPath, namespace, prom.TLSSecretRef.Name)) + if err != nil { + return "", err + } + } else { + s, err := secretClient.KubeClient.GetSecret(kube.ObjectKey(namespace, prom.TLSSecretRef.Name)) + if err != nil { + return "", xerrors.Errorf("could not read Prometheus TLS certificate: %w", err) + } + + if s.Type != corev1.SecretTypeTLS { + return "", xerrors.Errorf("secret containing the Prometheus TLS certificate needs to be of type kubernetes.io/tls") + } + + secretData = s.Data + } + + // We only need VerifyTLSSecretForStatefulSet to return the concatenated + // tls.key and tls.crt as Strings, but to not divert from the existing code, + // I'm still calling it, but that can be definitely improved. + // + // Make VerifyTLSSecretForStatefulSet receive `s.Data` but only return if it + // has been verified to be valid or not (boolean return). + // + // Use another function to concatenate tls.key and tls.crt into a `string`, + // or make `CreatePEMSecretClient` able to receive a byte[] instead on its + // `data` parameter. + data, err := VerifyTLSSecretForStatefulSet(secretData, Options{Replicas: 0}) + if err != nil { + return "", err + } + + // ReadHashFromSecret will read the Secret once again from Kubernetes API, + // we can improve this function by providing the Secret Data contents, + // instead of `secretClient`. + secretHash := enterprisepem.ReadHashFromSecret(secretClient, namespace, prom.TLSSecretRef.Name, secretPath, log) + err = CreatePEMSecretClient(secretClient, kube.ObjectKey(namespace, prom.TLSSecretRef.Name), map[string]string{secretHash: data}, []metav1.OwnerReference{}, podType) + if err != nil { + return "", xerrors.Errorf("error creating hashed Secret: %w", err) + } + + return secretHash, nil +} + +// ValidateSelfManagedSSLCertsForStatefulSet ensures that a stateful set using +// user-provided certificates has all of the relevant certificates in place. +func ValidateSelfManagedSSLCertsForStatefulSet(secretReadClient, secretWriteClient secrets.SecretClient, secretName string, opts Options, log *zap.SugaredLogger) workflow.Status { + // A "Certs" attribute has been provided + // This means that the customer has provided with a secret name they have + // already populated with the certs and keys for this deployment. + // Because of the async nature of Kubernetes, this object might not be ready yet, + // in which case, we'll keep reconciling until the object is created and is correct. + err := VerifyAndEnsureCertificatesForStatefulSet(secretReadClient, secretWriteClient, secretName, opts, log) + if err != nil { + return workflow.Failed(xerrors.Errorf("The secret object '%s' does not contain all the valid certificates needed: %w", secretName, err)) + } + + secretName = fmt.Sprintf("%s-pem", secretName) + + if err := ValidateCertificates(secretReadClient.KubeClient, secretName, opts.Namespace); err != nil { + return workflow.Failed(err) + } + + return workflow.OK() +} diff --git a/controllers/operator/common_controller.go b/controllers/operator/common_controller.go new file mode 100644 index 000000000..7a3a19894 --- /dev/null +++ b/controllers/operator/common_controller.go @@ -0,0 +1,1080 @@ +package operator + +import ( + "context" + "encoding/json" + "reflect" + + mdbcv1 "github.com/mongodb/mongodb-kubernetes-operator/api/v1" + "golang.org/x/xerrors" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/container" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/construct" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/watch" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/statefulset" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/certs" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/backup" + + "github.com/10gen/ops-manager-kubernetes/pkg/passwordhash" + kubernetesClient "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/client" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/configmap" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/secret" + + "encoding/pem" + "fmt" + "strings" + "time" + + v1 "github.com/10gen/ops-manager-kubernetes/api/v1" + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/api/v1/status" + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" + "github.com/10gen/ops-manager-kubernetes/pkg/vault" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/authentication" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/inspect" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/workflow" + "github.com/blang/semver" + + "sigs.k8s.io/controller-runtime/pkg/manager" + + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/secrets" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "go.uber.org/zap" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func automationConfigFirstMsg(resourceType string, valueToSet string) string { + return fmt.Sprintf("About to set `%s` to %s. automationConfig needs to be updated first", resourceType, valueToSet) +} + +type patchValue struct { + Op string `json:"op"` + Path string `json:"path"` + Value interface{} `json:"value"` +} + +// ReconcileCommonController is the "parent" controller that is included into each specific controller and allows +// to reuse the common functionality +type ReconcileCommonController struct { + // This client, initialized using mgr.Client() above, is a split client + // that reads objects from the cache and writes to the apiserver + scheme *runtime.Scheme + client kubernetesClient.Client + secrets.SecretClient + + watch.ResourceWatcher +} + +func newReconcileCommonController(mgr manager.Manager) *ReconcileCommonController { + newClient := kubernetesClient.NewClient(mgr.GetClient()) + var vaultClient *vault.VaultClient + + if vault.IsVaultSecretBackend() { + var err error + // creates the in-cluster config, we cannot use the controller-runtime manager client + // since the manager hasn't been started yet. Using it will cause error "the cache is not started, can not read objects" + config, err := rest.InClusterConfig() + if err != nil { + panic(err.Error()) + } + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + panic(err.Error()) + } + vaultClient, err = vault.InitVaultClient(clientset) + if err != nil { + panic(fmt.Sprintf("Can not initialize vault client: %s", err)) + } + if err := vaultClient.Login(); err != nil { + panic(xerrors.Errorf("unable to log in with vault client: %w", err)) + } + } + return &ReconcileCommonController{ + client: newClient, + SecretClient: secrets.SecretClient{ + VaultClient: vaultClient, + KubeClient: newClient, + }, + scheme: mgr.GetScheme(), + ResourceWatcher: watch.NewResourceWatcher(), + } +} + +func ensureRoles(roles []mdbv1.MongoDbRole, conn om.Connection, log *zap.SugaredLogger) workflow.Status { + d, err := conn.ReadDeployment() + if err != nil { + return workflow.Failed(err) + } + dRoles := d.GetRoles() + if reflect.DeepEqual(dRoles, roles) { + return workflow.OK() + } + // HELP-20798: the agent deals correctly with a null value for + // privileges only when creating a role, not when updating + // we work around it by explicitly passing empty array + for i, role := range roles { + if role.Privileges == nil { + roles[i].Privileges = []mdbv1.Privilege{} + } + } + err = conn.ReadUpdateDeployment( + func(d om.Deployment) error { + d.SetRoles(roles) + return nil + }, + log, + ) + if err != nil { + return workflow.Failed(err) + } + return workflow.OK() +} + +// updateStatus updates the status for the CR using patch operation. Note, that the resource status is mutated and +// it's important to pass resource by pointer to all methods which invoke current 'updateStatus'. +func (r *ReconcileCommonController) updateStatus(reconciledResource v1.CustomResourceReadWriter, status workflow.Status, log *zap.SugaredLogger, statusOptions ...status.Option) (reconcile.Result, error) { + status.Log(log) + + mergedOptions := append(statusOptions, status.StatusOptions()...) + reconciledResource.UpdateStatus(status.Phase(), mergedOptions...) + if err := r.patchUpdateStatus(reconciledResource, statusOptions...); err != nil { + log.Errorf("Error updating status to %s: %s", status.Phase(), err) + return reconcile.Result{}, err + } + return status.ReconcileResult() +} + +type WatcherResource interface { + ObjectKey() client.ObjectKey + GetSecurity() *mdbv1.Security + GetConnectionSpec() *mdbv1.ConnectionSpec +} + +// SetupCommonWatchers is the common shared method for all controller to watch the following resources: +// - OM related cm and secret +// - TLS related secrets, if enabled, this includes x509 internal authentication secrets +// +// Note: everything is watched under the same namespace as the objectKey +// in case getSecretNames func is nil, we will default to common mechanism to get the secret names +// TODO: unify the watcher setup with the secret creation/mounting code in database creation +func (r *ReconcileCommonController) SetupCommonWatchers(watcherResource WatcherResource, getTLSSecretNames func() []string, getInternalAuthSecretNames func() []string, resourceNameForSecret string) { + // We remove all watched resources + objectToReconcile := watcherResource.ObjectKey() + r.RemoveDependentWatchedResources(objectToReconcile) + + // And then add the ones we care about + connectionSpec := watcherResource.GetConnectionSpec() + if connectionSpec != nil { + r.RegisterWatchedMongodbResources(objectToReconcile, connectionSpec.GetProject(), connectionSpec.Credentials) + } + + security := watcherResource.GetSecurity() + // And TLS if needed + if security.IsTLSEnabled() { + var secretNames []string + if getTLSSecretNames != nil { + secretNames = getTLSSecretNames() + } else { + secretNames = []string{security.MemberCertificateSecretName(resourceNameForSecret)} // maybe here? + } + r.RegisterWatchedTLSResources(objectToReconcile, security.TLSConfig.CA, secretNames) + } + + if security.GetInternalClusterAuthenticationMode() == util.X509 { + var secretNames []string + if getInternalAuthSecretNames != nil { + secretNames = getInternalAuthSecretNames() + } else { + secretNames = []string{security.InternalClusterAuthSecretName(resourceNameForSecret)} + } + for _, secretName := range secretNames { + r.AddWatchedResourceIfNotAdded(secretName, objectToReconcile.Namespace, watch.Secret, objectToReconcile) + } + } +} + +// We fetch a fresh version in case any modifications have been made. +// Note, that this method enforces update ONLY to the status, so the reconciliation events happening because of this +// can be filtered out by 'controller.shouldReconcile' +// The "jsonPatch" merge allows to update only status field +func (r *ReconcileCommonController) patchUpdateStatus(resource v1.CustomResourceReadWriter, options ...status.Option) error { + payload := []patchValue{{ + Op: "replace", + Path: resource.GetStatusPath(options...), + // in most cases this will be "/status", but for each of the different Ops Manager components + // this will be different + Value: resource.GetStatus(options...), + }} + + data, err := json.Marshal(payload) + if err != nil { + return err + } + + patch := client.RawPatch(types.JSONPatchType, data) + err = r.client.Status().Patch(context.TODO(), resource, patch) + + if err != nil && apiErrors.IsInvalid(err) { + zap.S().Debug("The Status subresource might not exist yet, creating empty subresource") + if err := r.ensureStatusSubresourceExists(resource, options...); err != nil { + zap.S().Debug("Error from ensuring status subresource: %s", err) + return err + } + return r.client.Status().Patch(context.TODO(), resource, patch) + } + + return nil +} + +type emptyPayload struct{} + +// ensureStatusSubresourceExists ensures that the status subresource section we are trying to write to exists. +// if we just try and patch the full path directly, the subresource sections are not recursively created, so +// we need to ensure that the actual object we're trying to write to exists, otherwise we will get errors. +func (r *ReconcileCommonController) ensureStatusSubresourceExists(resource v1.CustomResourceReadWriter, options ...status.Option) error { + fullPath := resource.GetStatusPath(options...) + parts := strings.Split(fullPath, "/") + + if strings.HasPrefix(fullPath, "/") { + parts = parts[1:] + } + + var path []string + for _, part := range parts { + pathStr := "/" + strings.Join(path, "/") + path = append(path, part) + emptyPatchPayload := []patchValue{{ + Op: "add", + Path: pathStr, + Value: emptyPayload{}, + }} + data, err := json.Marshal(emptyPatchPayload) + if err != nil { + return err + } + patch := client.RawPatch(types.JSONPatchType, data) + if err := r.client.Status().Patch(context.TODO(), resource, patch); err != nil && !apiErrors.IsInvalid(err) { + return err + } + } + return nil +} + +// getResource populates the provided runtime.Object with some additional error handling +func (r *ReconcileCommonController) getResource(request reconcile.Request, resource v1.CustomResourceReadWriter, log *zap.SugaredLogger) (reconcile.Result, error) { + err := r.client.Get(context.TODO(), request.NamespacedName, resource) + if err != nil { + if apiErrors.IsNotFound(err) { + // Request object not found, could have been deleted after reconcile request. + // Return and don't requeue + log.Debugf("Object %s doesn't exist, was it deleted after reconcile request?", request.NamespacedName) + return reconcile.Result{}, err + } + // Error reading the object - requeue the request. + log.Errorf("Failed to query object %s: %s", request.NamespacedName, err) + return reconcile.Result{RequeueAfter: 10 * time.Second}, err + } + return reconcile.Result{}, nil +} + +// prepareResourceForReconciliation finds the object being reconciled. Returns the reconcile result and any error that +// occurred. +func (r *ReconcileCommonController) prepareResourceForReconciliation( + request reconcile.Request, resource v1.CustomResourceReadWriter, log *zap.SugaredLogger) (reconcile.Result, error) { + if result, err := r.getResource(request, resource, log); err != nil { + return result, err + } + + result, err := r.updateStatus(resource, workflow.Reconciling(), log) + if err != nil { + return result, err + } + + // Reset warnings so that they are not stale, will populate accurate warnings in reconciliation + resource.SetWarnings([]status.Warning{}) + + return reconcile.Result{}, nil +} + +// checkIfHasExcessProcesses will check if the project has excess processes. +// Also, it removes the tag ExternallyManaged from the project in this case as +// the user may need to clean the resources from OM UI if they move the +// resource to another project (as recommended by the migration instructions). +func checkIfHasExcessProcesses(conn om.Connection, resource *mdbv1.MongoDB, log *zap.SugaredLogger) workflow.Status { + deployment, err := conn.ReadDeployment() + if err != nil { + return workflow.Failed(err) + } + excessProcesses := deployment.GetNumberOfExcessProcesses(resource.Name) + if excessProcesses == 0 { + // cluster is empty or this resource is the only one living on it + return workflow.OK() + } + // remove tags if multiple clusters in project + groupWithTags := &om.Project{ + Name: conn.GroupName(), + OrgID: conn.OrgID(), + ID: conn.GroupID(), + Tags: []string{}, + } + _, err = conn.UpdateProject(groupWithTags) + if err != nil { + log.Warnw("could not remove externally managed tag from Ops Manager group", "error", err) + } + + return workflow.Pending("cannot have more than 1 MongoDB Cluster per project (see https://docs.mongodb.com/kubernetes-operator/stable/tutorial/migrate-to-single-resource/)") +} + +// validateInternalClusterCertsAndCheckTLSType verifies that all the x509 internal cluster certs exist and return whether they are built following the kubernetes.io/tls secret type (tls.crt/tls.key entries). +// TODO: this is almost the same as certs.EnsureSSLCertsForStatefulSet, we should centralize the functionality +func (r *ReconcileCommonController) validateInternalClusterCertsAndCheckTLSType(configurator certs.X509CertConfigurator, opts certs.Options, log *zap.SugaredLogger) error { + + secretName := opts.InternalClusterSecretName + + err := certs.VerifyAndEnsureCertificatesForStatefulSet( + configurator.GetSecretReadClient(), + configurator.GetSecretWriteClient(), + secretName, opts, log) + if err != nil { + return xerrors.Errorf("the secret object '%s' does not contain all the certificates needed: %w", secretName, err) + } + + secretName = fmt.Sprintf("%s%s", secretName, certs.OperatorGeneratedCertSuffix) + + // Validates that the secret is valid + if err := certs.ValidateCertificates(r.client, secretName, opts.Namespace); err != nil { + return err + } + return nil +} + +// ensureBackupConfigurationAndUpdateStatus configures backup in Ops Manager based on the MongoDB resources spec +func (r *ReconcileCommonController) ensureBackupConfigurationAndUpdateStatus(conn om.Connection, mdb backup.ConfigReaderUpdater, secretsReader secrets.SecretClient, log *zap.SugaredLogger) workflow.Status { + statusOpt, opts := backup.EnsureBackupConfigurationInOpsManager(mdb, secretsReader, conn.GroupID(), conn, conn, conn, log) + if len(opts) > 0 { + if _, err := r.updateStatus(mdb, statusOpt, log, opts...); err != nil { + return workflow.Failed(err) + } + } + return statusOpt +} + +// validateMongoDBResource performs validation on the MongoDBResource +func validateMongoDBResource(mdb *mdbv1.MongoDB, conn om.Connection) workflow.Status { + ac, err := conn.ReadAutomationConfig() + if err != nil { + return workflow.Failed(err) + } + + if status := validateScram(mdb, ac); !status.IsOK() { + return status + } + + return workflow.OK() +} + +func ensureSupportedOpsManagerVersion(conn om.Connection) workflow.Status { + omVersionString := conn.OpsManagerVersion() + if !omVersionString.IsCloudManager() { + omVersion, err := omVersionString.Semver() + if err != nil { + return workflow.Failed(xerrors.Errorf("Failed when trying to parse Ops Manager version")) + } + if omVersion.LT(semver.MustParse(oldestSupportedOpsManagerVersion)) { + return workflow.Unsupported("This MongoDB ReplicaSet is managed by Ops Manager version %s, which is not supported by this version of the operator. Please upgrade it to a version >=%s", omVersion, oldestSupportedOpsManagerVersion) + + } + } + return workflow.OK() +} + +// scaleStatefulSet sets the number of replicas for a StatefulSet and returns a reference of the updated resource. +func (r *ReconcileCommonController) scaleStatefulSet(namespace, name string, replicas int32) (appsv1.StatefulSet, error) { + if set, err := r.client.GetStatefulSet(kube.ObjectKey(namespace, name)); err != nil { + return set, err + } else { + set.Spec.Replicas = &replicas + return r.client.UpdateStatefulSet(set) + } + +} + +// getStatefulSetStatus returns the workflow.Status based on the status of the StatefulSet. +// If the StatefulSet is not ready the request will be retried in 3 seconds (instead of the default 10 seconds) +// allowing to reach "ready" status sooner +func getStatefulSetStatus(namespace, name string, client kubernetesClient.Client) workflow.Status { + set, err := client.GetStatefulSet(kube.ObjectKey(namespace, name)) + i := 0 + + // Sometimes it is possible that the StatefulSet which has just been created + // returns a not found error when getting it too soon afterwards. + for apiErrors.IsNotFound(err) && i < 10 { + i++ + zap.S().Debugf("StatefulSet was not found: %s, attempt %d", err, i) + time.Sleep(time.Second * 1) + set, err = client.GetStatefulSet(kube.ObjectKey(namespace, name)) + } + + if err != nil { + return workflow.Failed(err) + } + + if statefulSetState := inspect.StatefulSet(set); !statefulSetState.IsReady() { + return workflow. + Pending(statefulSetState.GetMessage()). + WithResourcesNotReady(statefulSetState.GetResourcesNotReadyStatus()). + WithRetry(3) + } + return workflow.OK() +} + +// validateScram ensures that the SCRAM configuration is valid for the MongoDBResource +func validateScram(mdb *mdbv1.MongoDB, ac *om.AutomationConfig) workflow.Status { + specVersion, err := semver.Make(util.StripEnt(mdb.Spec.GetMongoDBVersion())) + if err != nil { + return workflow.Failed(err) + } + + scram256IsAlreadyEnabled := stringutil.Contains(ac.Auth.DeploymentAuthMechanisms, string(authentication.ScramSha256)) + attemptingToDowngradeMongoDBVersion := ac.Deployment.MinimumMajorVersion() >= 4 && specVersion.Major < 4 + isDowngradingFromScramSha256ToScramSha1 := attemptingToDowngradeMongoDBVersion && stringutil.Contains(mdb.Spec.Security.Authentication.GetModes(), "SCRAM") && scram256IsAlreadyEnabled + + if isDowngradingFromScramSha256ToScramSha1 { + return workflow.Invalid("Unable to downgrade to SCRAM-SHA-1 when SCRAM-SHA-256 has been enabled") + } + + return workflow.OK() +} + +// Use the first "CERTIFICATE" block found in the PEM file. +func getSubjectFromCertificate(cert string) (string, error) { + block, rest := pem.Decode([]byte(cert)) + if block != nil && block.Type == "CERTIFICATE" { + subjects, _, err := authentication.GetCertificateSubject(cert) + if err != nil { + return "", err + } + return subjects, nil + } + if len(rest) > 0 { + subjects, _, err := authentication.GetCertificateSubject(string(rest)) + if err != nil { + return "", err + } + return subjects, nil + } + return "", xerrors.Errorf("unable to extract the subject line from the provided certificate") +} + +// updateOmAuthentication examines the state of Ops Manager and the desired state of the MongoDB resource and +// enables/disables authentication. If the authentication can't be fully configured, a boolean value indicating that +// an additional reconciliation needs to be queued up to fully make the authentication changes is returned. +// Note: updateOmAuthentication needs to be called before reconciling other auth related settings. +func (r *ReconcileCommonController) updateOmAuthentication(conn om.Connection, processNames []string, ar authentication.AuthResource, agentCertSecretName string, caFilepath string, clusterFilePath string, log *zap.SugaredLogger) (status workflow.Status, multiStageReconciliation bool) { + // don't touch authentication settings if resource has not been configured with them + if ar.GetSecurity() == nil || ar.GetSecurity().Authentication == nil { + return workflow.OK(), false + } + + ac, err := conn.ReadAutomationConfig() + if err != nil { + return workflow.Failed(err), false + } + + // if we have changed the internal cluster auth, we need to update the ac first + authenticationMode := ar.GetSecurity().GetInternalClusterAuthenticationMode() + err = r.setupInternalClusterAuthIfItHasChanged(conn, processNames, authenticationMode, clusterFilePath) + if err != nil { + return workflow.Failed(err), false + } + + // we need to wait for all agents to be ready before configuring any authentication settings + if err := om.WaitForReadyState(conn, processNames, log); err != nil { + return workflow.Failed(err), false + } + + clientCerts := util.OptionalClientCertficates + if ar.GetSecurity().RequiresClientTLSAuthentication() { + clientCerts = util.RequireClientCertificates + } + + scramAgentUserName := util.AutomationAgentUserName + // only use the default name if there is not already a configured username + if ac.Auth.AutoUser != "" && ac.Auth.AutoUser != scramAgentUserName { + scramAgentUserName = ac.Auth.AutoUser + } + + authOpts := authentication.Options{ + MinimumMajorVersion: ar.GetMinimumMajorVersion(), + Mechanisms: ar.GetSecurity().Authentication.Modes, + ProcessNames: processNames, + AuthoritativeSet: !ar.GetSecurity().Authentication.IgnoreUnknownUsers, + AgentMechanism: ar.GetSecurity().GetAgentMechanism(ac.Auth.AutoAuthMechanism), + ClientCertificates: clientCerts, + AutoUser: scramAgentUserName, + AutoLdapGroupDN: ar.GetSecurity().Authentication.Agents.AutomationLdapGroupDN, + CAFilePath: caFilepath, + } + var databaseSecretPath string + if r.VaultClient != nil { + databaseSecretPath = r.VaultClient.DatabaseSecretPath() + } + if ar.IsLDAPEnabled() { + bindUserPassword, err := r.ReadSecretKey(kube.ObjectKey(ar.GetNamespace(), ar.GetSecurity().Authentication.Ldap.BindQuerySecretRef.Name), databaseSecretPath, "password") + + if err != nil { + return workflow.Failed(xerrors.Errorf("error reading bind user password: %w", err)), false + } + + caContents := "" + ca := ar.GetSecurity().Authentication.Ldap.CAConfigMapRef + if ca != nil { + log.Debugf("Sending CA file to Pods via AutomationConfig: %s/%s/%s", ar.GetNamespace(), ca.Name, ca.Key) + caContents, err = configmap.ReadKey(r.client, ca.Key, types.NamespacedName{Name: ca.Name, Namespace: ar.GetNamespace()}) + if err != nil { + return workflow.Failed(xerrors.Errorf("error reading CA configmap: %w", err)), false + } + } + + authOpts.Ldap = ar.GetLDAP(bindUserPassword, caContents) + } + + log.Debugf("Using authentication options %+v", authentication.Redact(authOpts)) + + agentSecretSelector := ar.GetSecurity().AgentClientCertificateSecretName(ar.GetName()) + if agentCertSecretName != "" { + agentSecretSelector.Name = agentCertSecretName + } + wantToEnableAuthentication := ar.GetSecurity().Authentication.Enabled + if wantToEnableAuthentication && canConfigureAuthentication(ac, ar.GetSecurity().Authentication.GetModes(), log) { + log.Info("Configuring authentication for MongoDB resource") + + if ar.GetSecurity().ShouldUseX509(ac.Auth.AutoAuthMechanism) || ar.GetSecurity().ShouldUseClientCertificates() { + agentSecret := &corev1.Secret{} + if err := r.client.Get(context.TODO(), kube.ObjectKey(ar.GetNamespace(), agentSecretSelector.Name), agentSecret); client.IgnoreNotFound(err) != nil { + return workflow.Failed(err), false + } + // If the agent secret is of type TLS, we can find the certificate under the standard key, + // otherwise the concatenated PEM secret would contain the certificate and keys under the selector's + // Key identifying the agent. + // In single cluster workloads this path is working with the concatenated PEM secret, + // + // Important: In multi cluster it is working with the TLS secret in the central cluster, hence below selector update. + if agentSecret.Type == corev1.SecretTypeTLS { + agentSecretSelector.Key = corev1.TLSCertKey + } + + authOpts, err = r.configureAgentSubjects(ar.GetNamespace(), agentSecretSelector, authOpts, log) + if err != nil { + return workflow.Failed(xerrors.Errorf("error configuring agent subjects: %w", err)), false + } + authOpts.AgentsShouldUseClientAuthentication = ar.GetSecurity().ShouldUseClientCertificates() + + } + if ar.GetSecurity().ShouldUseLDAP(ac.Auth.AutoAuthMechanism) { + secretRef := ar.GetSecurity().Authentication.Agents.AutomationPasswordSecretRef + autoConfigPassword, err := r.ReadSecretKey(kube.ObjectKey(ar.GetNamespace(), secretRef.Name), databaseSecretPath, secretRef.Key) + if err != nil { + return workflow.Failed(xerrors.Errorf("error reading automation agent password: %w", err)), false + } + + authOpts.AutoPwd = autoConfigPassword + userOpts := authentication.UserOptions{} + agentName := ar.GetSecurity().Authentication.Agents.AutomationUserName + userOpts.AutomationSubject = agentName + authOpts.UserOptions = userOpts + } + + if err := authentication.Configure(conn, authOpts, log); err != nil { + return workflow.Failed(err), false + } + } else if wantToEnableAuthentication { + // The MongoDB resource has been configured with a type of authentication + // but the current state in Ops Manager does not allow a direct transition. This will require + // an additional reconciliation after a partial update to Ops Manager. + log.Debug("Attempting to enable authentication, but Ops Manager state will not allow this") + return workflow.OK(), true + } else { + agentSecret := &corev1.Secret{} + if err := r.client.Get(context.TODO(), kube.ObjectKey(ar.GetNamespace(), agentSecretSelector.Name), agentSecret); client.IgnoreNotFound(err) != nil { + return workflow.Failed(err), false + } + + if agentSecret.Type == corev1.SecretTypeTLS { + agentSecretSelector.Name = fmt.Sprintf("%s%s", agentSecretSelector.Name, certs.OperatorGeneratedCertSuffix) + } + + // Should not fail if the Secret object with agent certs is not found. + // It will only exist on x509 client auth enabled deployments. + userOpts, err := r.readAgentSubjectsFromSecret(ar.GetNamespace(), agentSecretSelector, log) + err = client.IgnoreNotFound(err) + if err != nil { + return workflow.Failed(err), true + } + + authOpts.UserOptions = userOpts + if err := authentication.Disable(conn, authOpts, false, log); err != nil { + return workflow.Failed(err), false + } + } + return workflow.OK(), false +} + +// configureAgentSubjects returns a new authentication.Options which has configured the Subject lines for the automation agent. +// The Ops Manager user names for these agents will be configured based on the contents of the secret. +func (r *ReconcileCommonController) configureAgentSubjects(namespace string, secretKeySelector corev1.SecretKeySelector, authOpts authentication.Options, log *zap.SugaredLogger) (authentication.Options, error) { + userOpts, err := r.readAgentSubjectsFromSecret(namespace, secretKeySelector, log) + if err != nil { + return authentication.Options{}, xerrors.Errorf("error reading agent subjects from secret: %w", err) + } + authOpts.UserOptions = userOpts + return authOpts, nil +} + +func (r *ReconcileCommonController) readAgentSubjectsFromSecret(namespace string, secretKeySelector corev1.SecretKeySelector, log *zap.SugaredLogger) (authentication.UserOptions, error) { + userOpts := authentication.UserOptions{} + + var databaseSecretPath string + if r.VaultClient != nil { + databaseSecretPath = r.VaultClient.DatabaseSecretPath() + } + agentCerts, err := r.ReadSecret(kube.ObjectKey(namespace, secretKeySelector.Name), databaseSecretPath) + if err != nil { + return userOpts, err + } + + automationAgentCert, ok := agentCerts[secretKeySelector.Key] + if !ok { + return userOpts, xerrors.Errorf("could not find certificate with name %s", secretKeySelector.Key) + } + + automationAgentSubject, err := getSubjectFromCertificate(automationAgentCert) + if err != nil { + return userOpts, xerrors.Errorf("error extracting automation agent subject is not present %w", err) + } + + log.Debugf("Automation certificate subject is %s", automationAgentSubject) + + return authentication.UserOptions{ + AutomationSubject: automationAgentSubject, + }, nil +} + +func (r *ReconcileCommonController) clearProjectAuthenticationSettings(conn om.Connection, mdb *mdbv1.MongoDB, processNames []string, log *zap.SugaredLogger) error { + secretKeySelector := mdb.Spec.Security.AgentClientCertificateSecretName(mdb.Name) + agentSecret := &corev1.Secret{} + if err := r.client.Get(context.TODO(), kube.ObjectKey(mdb.Namespace, secretKeySelector.Name), agentSecret); client.IgnoreNotFound(err) != nil { + return nil + } + + if agentSecret.Type == corev1.SecretTypeTLS { + secretKeySelector.Name = fmt.Sprintf("%s%s", secretKeySelector.Name, certs.OperatorGeneratedCertSuffix) + } + + userOpts, err := r.readAgentSubjectsFromSecret(mdb.Namespace, secretKeySelector, log) + err = client.IgnoreNotFound(err) + if err != nil { + return err + } + log.Infof("Disabling authentication for project: %s", conn.GroupName()) + disableOpts := authentication.Options{ + ProcessNames: processNames, + UserOptions: userOpts, + } + + return authentication.Disable(conn, disableOpts, true, log) +} + +// ensureX509SecretAndCheckTLSType checks if the secrets containing the certificates are present and whether the certificate are of kubernetes.io/tls type. +func (r *ReconcileCommonController) ensureX509SecretAndCheckTLSType(configurator certs.X509CertConfigurator, currentAuthMechanism string, log *zap.SugaredLogger) workflow.Status { + authSpec := configurator.GetDbCommonSpec().GetSecurity().Authentication + if authSpec == nil || !configurator.GetDbCommonSpec().GetSecurity().Authentication.Enabled { + return workflow.OK() + } + + if configurator.GetDbCommonSpec().GetSecurity().ShouldUseX509(currentAuthMechanism) { + if !configurator.GetDbCommonSpec().GetSecurity().IsTLSEnabled() { + return workflow.Failed(xerrors.Errorf("Authentication mode for project is x509 but this MDB resource is not TLS enabled")) + } + agentSecretName := configurator.GetDbCommonSpec().GetSecurity().AgentClientCertificateSecretName(configurator.GetName()).Name + err := certs.VerifyAndEnsureClientCertificatesForAgentsAndTLSType( + configurator.GetSecretReadClient(), configurator.GetSecretWriteClient(), + kube.ObjectKey(configurator.GetNamespace(), agentSecretName), + log) + if err != nil { + return workflow.Failed(err) + } + } + + if configurator.GetDbCommonSpec().GetSecurity().GetInternalClusterAuthenticationMode() == util.X509 { + errors := make([]error, 0) + for _, certOption := range configurator.GetCertOptions() { + err := r.validateInternalClusterCertsAndCheckTLSType(configurator, certOption, log) + if err != nil { + errors = append(errors, err) + } + } + if len(errors) > 0 { + return workflow.Failed(xerrors.Errorf("failed ensuring internal cluster authentication certs %w", errors[0])) + } + } + + // if client certificate is configured for the agent, create corresponding concatenated pem certs + if configurator.GetDbCommonSpec().GetSecurity().ShouldUseClientCertificates() { + agentSecretName := configurator.GetDbCommonSpec().GetSecurity().AgentClientCertificateSecretName(configurator.GetName()) + err := certs.VerifyAndEnsureClientCertificatesForAgentsAndTLSType(configurator.GetSecretReadClient(), configurator.GetSecretWriteClient(), + kube.ObjectKey(configurator.GetNamespace(), agentSecretName.Name), log) + if err != nil { + return workflow.Failed(err) + } + } + + return workflow.OK() +} + +// setupInternalClusterAuthIfItHasChanged enables internal cluster auth if possible in case the path has changed and did exist before. +func (r *ReconcileCommonController) setupInternalClusterAuthIfItHasChanged(conn om.Connection, names []string, clusterAuth string, filePath string) error { + if filePath == "" { + return nil + } + err := conn.ReadUpdateDeployment(func(deployment om.Deployment) error { + deployment.SetInternalClusterFilePathOnlyIfItThePathHasChanged(names, filePath, clusterAuth) + return nil + }, zap.S()) + return err +} + +// isPrometheusSupported checks if Prometheus integration can be enabled. +// +// Prometheus is only enabled in Cloud Manager and Ops Manager 5.9 (6.0) and above. +func isPrometheusSupported(conn om.Connection) bool { + if conn.OpsManagerVersion().IsCloudManager() { + return true + } + + omVersion, err := conn.OpsManagerVersion().Semver() + return err == nil && omVersion.GTE(semver.MustParse("5.9.0")) +} + +// UpdatePrometheus configures Prometheus on the Deployment for this resource. +func UpdatePrometheus(d *om.Deployment, conn om.Connection, prometheus *mdbcv1.Prometheus, sClient secrets.SecretClient, namespace string, certName string, log *zap.SugaredLogger) error { + if prometheus == nil { + return nil + } + + if !isPrometheusSupported(conn) { + log.Info("Prometheus can't be enabled, Prometheus is not supported in this version of Ops Manager") + return nil + } + + var err error + var password string + + secretName := prometheus.PasswordSecretRef.Name + if vault.IsVaultSecretBackend() { + operatorSecretPath := sClient.VaultClient.OperatorSecretPath() + passwordString := fmt.Sprintf("%s/%s/%s", operatorSecretPath, namespace, secretName) + keyedPassword, err := sClient.VaultClient.ReadSecretString(passwordString) + if err != nil { + log.Infof("Prometheus can't be enabled, %s", err) + return err + } + + var ok bool + password, ok = keyedPassword[prometheus.GetPasswordKey()] + if !ok { + errMsg := fmt.Sprintf("Prometheus password %s not in Secret %s", prometheus.GetPasswordKey(), passwordString) + log.Info(errMsg) + return xerrors.Errorf(errMsg) + } + } else { + secretNamespacedName := types.NamespacedName{Name: secretName, Namespace: namespace} + password, err = secret.ReadKey(sClient, prometheus.GetPasswordKey(), secretNamespacedName) + if err != nil { + log.Infof("Prometheus can't be enabled, %s", err) + return err + } + } + + hash, salt := passwordhash.GenerateHashAndSaltForPassword(password) + d.ConfigurePrometheus(prometheus, hash, salt, certName) + + return nil +} + +// canConfigureAuthentication determines if based on the existing state of Ops Manager +// it is possible to configure the authentication mechanisms specified by the given MongoDB resource +// during this reconciliation. This function may return a different value on the next reconciliation +// if the state of Ops Manager has been changed. +func canConfigureAuthentication(ac *om.AutomationConfig, authenticationModes []string, log *zap.SugaredLogger) bool { + attemptingToEnableX509 := !stringutil.Contains(ac.Auth.DeploymentAuthMechanisms, util.AutomationConfigX509Option) && stringutil.Contains(authenticationModes, util.X509) + canEnableX509InOpsManager := ac.Deployment.AllProcessesAreTLSEnabled() || ac.Deployment.NumberOfProcesses() == 0 + + log.Debugw("canConfigureAuthentication", + "attemptingToEnableX509", attemptingToEnableX509, + "deploymentAuthMechanisms", ac.Auth.DeploymentAuthMechanisms, + "modes", authenticationModes, + "canEnableX509InOpsManager", canEnableX509InOpsManager, + "allProcessesAreTLSEnabled", ac.Deployment.AllProcessesAreTLSEnabled(), + "numberOfProcesses", ac.Deployment.NumberOfProcesses()) + + if attemptingToEnableX509 { + return canEnableX509InOpsManager + } + + // x509 is the only mechanism with restrictions determined based on Ops Manager state + return true +} + +// newPodVars initializes a PodEnvVars instance based on the values of the provided Ops Manager connection, project config +// and connection spec +func newPodVars(conn om.Connection, projectConfig mdbv1.ProjectConfig, spec mdbv1.ConnectionSpec) *env.PodEnvVars { + podVars := &env.PodEnvVars{} + podVars.BaseURL = conn.BaseURL() + podVars.ProjectID = conn.GroupID() + podVars.User = conn.PublicKey() + podVars.LogLevel = string(spec.LogLevel) + podVars.SSLProjectConfig = projectConfig.SSLProjectConfig + return podVars +} + +func getVolumeFromStatefulSet(sts appsv1.StatefulSet, name string) (corev1.Volume, error) { + for _, v := range sts.Spec.Template.Spec.Volumes { + if v.Name == name { + return v, nil + } + } + return corev1.Volume{}, xerrors.Errorf("can't find volume %s in list of volumes: %v", name, sts.Spec.Template.Spec.Volumes) +} + +// wasTLSSecretMounted checks whether or not TLS was previously enabled by looking at the state of the volumeMounts of the pod. +func wasTLSSecretMounted(secretGetter secret.Getter, currentSts appsv1.StatefulSet, volumeMounts []corev1.VolumeMount, mdb mdbv1.MongoDB, log *zap.SugaredLogger) bool { + + tlsVolume, err := getVolumeFromStatefulSet(currentSts, util.SecretVolumeName) + if err != nil { + return false + } + + // With the new design, the volume is always mounted + // But it is marked with optional. + // + // TLS was enabled if the secret it refers to is present + + secretName := tlsVolume.Secret.SecretName + exists, err := secret.Exists(secretGetter, types.NamespacedName{ + Namespace: mdb.Namespace, + Name: secretName}, + ) + if err != nil { + log.Warnf("can't determine whether the TLS certificate secret exists or not: %s. Will assume it doesn't", err) + return false + } + log.Debugf("checking if secret %s exists: %v", secretName, exists) + + return exists + +} + +// wasCAConfigMapMounted checks whether or not the CA ConfigMap by looking at the state of the volumeMounts of the pod. +func wasCAConfigMapMounted(configMapGetter configmap.Getter, currentSts appsv1.StatefulSet, volumeMounts []corev1.VolumeMount, mdb mdbv1.MongoDB, log *zap.SugaredLogger) bool { + + caVolume, err := getVolumeFromStatefulSet(currentSts, util.ConfigMapVolumeCAMountPath) + if err != nil { + return false + } + + // With the new design, the volume is always mounted + // But it is marked with optional. + // + // The configMap was mounted if the configMap it refers to is present + + cmName := caVolume.ConfigMap.Name + exists, err := configmap.Exists(configMapGetter, types.NamespacedName{ + Namespace: mdb.Namespace, + Name: cmName}, + ) + if err != nil { + log.Warnf("can't determine whether the TLS ConfigMap exists or not: %s. Will assume it doesn't", err) + return false + } + log.Debugf("checking if ConfigMap %s exists: %v", cmName, exists) + + return exists +} + +type ConfigMapStatefulSetSecretGetter interface { + statefulset.Getter + secret.Getter + configmap.Getter +} + +// needToPublishStateFirst will check if the Published State of the StatfulSet backed MongoDB Deployments +// needs to be updated first. In the case of unmounting certs, for instance, the certs should be not +// required anymore before we unmount them, or the automation-agent and readiness probe will never +// reach goal state. +func needToPublishStateFirst(getter ConfigMapStatefulSetSecretGetter, mdb mdbv1.MongoDB, configFunc func(mdb mdbv1.MongoDB) construct.DatabaseStatefulSetOptions, log *zap.SugaredLogger) bool { + opts := configFunc(mdb) + + namespacedName := kube.ObjectKey(mdb.Namespace, opts.Name) + currentSts, err := getter.GetStatefulSet(namespacedName) + if err != nil { + if apiErrors.IsNotFound(err) { + // No need to publish state as this is a new StatefulSet + log.Debugf("New StatefulSet %s", namespacedName) + return false + } + + log.Debugw(fmt.Sprintf("Error getting StatefulSet %s", namespacedName), "error", err) + return false + } + + databaseContainer := container.GetByName(util.DatabaseContainerName, currentSts.Spec.Template.Spec.Containers) + volumeMounts := databaseContainer.VolumeMounts + + if !mdb.Spec.Security.IsTLSEnabled() && wasTLSSecretMounted(getter, currentSts, volumeMounts, mdb, log) { + log.Debug(automationConfigFirstMsg("security.tls.enabled", "false")) + return true + } + + if mdb.Spec.Security.TLSConfig.CA == "" && wasCAConfigMapMounted(getter, currentSts, volumeMounts, mdb, log) { + log.Debug(automationConfigFirstMsg("security.tls.CA", "empty")) + return true + + } + + if opts.PodVars.SSLMMSCAConfigMap == "" && statefulset.VolumeMountWithNameExists(volumeMounts, construct.CaCertName) { + log.Debug(automationConfigFirstMsg("SSLMMSCAConfigMap", "empty")) + return true + } + + if mdb.Spec.Security.GetAgentMechanism(opts.CurrentAgentAuthMode) != util.X509 && statefulset.VolumeMountWithNameExists(volumeMounts, util.AgentSecretName) { + log.Debug(automationConfigFirstMsg("project.AuthMode", "empty")) + return true + } + + if int32(opts.Replicas) < *currentSts.Spec.Replicas { + log.Debug("Scaling down operation. automationConfig needs to be updated first") + return true + } + + return false +} + +// completionMessage is just a general message printed in the logs after mongodb resource is created/updated +func completionMessage(url, projectID string) string { + return fmt.Sprintf("Please check the link %s/v2/%s to see the status of the deployment", url, projectID) +} + +// mongodbCleanUpOptions implements the required interface to be passed +// to the DeleteAllOf function, this cleans up resources of a given type with +// the provided labels in a specific namespace. +type mongodbCleanUpOptions struct { + namespace string + labels map[string]string +} + +func (m *mongodbCleanUpOptions) ApplyToDeleteAllOf(opts *client.DeleteAllOfOptions) { + opts.Namespace = m.namespace + opts.LabelSelector = labels.SelectorFromValidatedSet(m.labels) +} + +// getAnnotationsForResource returns all of the annotations that should be applied to the resource +// at the end of the reconciliation. The additional mongod options must be manually +// set as the wrapper type we use prevents a regular `json.Marshal` from working in this case due to +// the `json "-"` tag. +func getAnnotationsForResource(mdb *mdbv1.MongoDB) (map[string]string, error) { + finalAnnotations := make(map[string]string) + specBytes, err := json.Marshal(mdb.Spec) + if err != nil { + return nil, err + } + finalAnnotations[util.LastAchievedSpec] = string(specBytes) + + switch mdb.Spec.ResourceType { + case mdbv1.Standalone, mdbv1.ReplicaSet: + additionalConfigBytes, err := json.Marshal(mdb.Spec.AdditionalMongodConfig.ToMap()) + if err != nil { + return nil, err + } + finalAnnotations[util.LastAchievedMongodAdditionalOptions] = string(additionalConfigBytes) + case mdbv1.ShardedCluster: + if mdb.Spec.ShardSpec != nil { + additionalShardBytes, err := json.Marshal(mdb.Spec.ShardSpec.AdditionalMongodConfig.ToMap()) + if err != nil { + return nil, err + } + finalAnnotations[util.LastAchievedMongodAdditionalShardOptions] = string(additionalShardBytes) + } + + if mdb.Spec.MongosSpec != nil { + additionalMongosBytes, err := json.Marshal(mdb.Spec.MongosSpec.AdditionalMongodConfig.ToMap()) + if err != nil { + return nil, err + } + finalAnnotations[util.LastAchievedMongodAdditionalMongosOptions] = string(additionalMongosBytes) + } + + if mdb.Spec.ConfigSrvSpec != nil { + additionalConfigServerBytes, err := json.Marshal(mdb.Spec.ConfigSrvSpec.AdditionalMongodConfig.ToMap()) + if err != nil { + return nil, err + } + finalAnnotations[util.LastAchievedMongodAdditionalConfigServerOptions] = string(additionalConfigServerBytes) + } + + } + return finalAnnotations, nil +} + +type PrometheusConfiguration struct { + prometheus *mdbcv1.Prometheus + conn om.Connection + secretsClient secrets.SecretClient + namespace string + prometheusCertHash string +} + +func ReconcileReplicaSetAC(d om.Deployment, processes []om.Process, spec mdbv1.DbCommonSpec, lastMongodConfig map[string]interface{}, resourceName string, rs om.ReplicaSetWithProcesses, caFilePath string, internalClusterPath string, pc *PrometheusConfiguration, log *zap.SugaredLogger) error { + // it is not possible to disable internal cluster authentication once enabled + if d.ExistingProcessesHaveInternalClusterAuthentication(processes) && spec.Security.GetInternalClusterAuthenticationMode() == "" { + return xerrors.Errorf("cannot disable x509 internal cluster authentication") + } + + excessProcesses := d.GetNumberOfExcessProcesses(resourceName) + if excessProcesses > 0 { + return xerrors.Errorf("cannot have more than 1 MongoDB Cluster per project (see https://docs.mongodb.com/kubernetes-operator/stable/tutorial/migrate-to-single-resource/)") + } + + d.MergeReplicaSet(rs, spec.GetAdditionalMongodConfig().ToMap(), lastMongodConfig, log) + d.AddMonitoringAndBackup(log, spec.GetSecurity().IsTLSEnabled(), caFilePath) + d.ConfigureTLS(spec.GetSecurity(), caFilePath) + d.ConfigureInternalClusterAuthentication(rs.GetProcessNames(), spec.GetSecurity().GetInternalClusterAuthenticationMode(), internalClusterPath) + + // if we don't set up a prometheus connection, then we don't want to set up prometheus for instance because we do not support it yet. + if pc != nil { + // At this point we won't bubble-up the error we got from this + // function, we don't want to fail the MongoDB resource because + // Prometheus can't be enabled. + _ = UpdatePrometheus(&d, pc.conn, pc.prometheus, pc.secretsClient, pc.namespace, pc.prometheusCertHash, log) + } + + return nil +} diff --git a/controllers/operator/common_controller_test.go b/controllers/operator/common_controller_test.go new file mode 100644 index 000000000..2fc7ab6af --- /dev/null +++ b/controllers/operator/common_controller_test.go @@ -0,0 +1,469 @@ +package operator + +import ( + "context" + "io/ioutil" + "os" + "reflect" + "strings" + "testing" + "time" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/watch" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/types" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/agents" + "golang.org/x/xerrors" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/deployment" + + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/connection" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/project" + + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + kubernetesClient "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/client" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + omv1 "github.com/10gen/ops-manager-kubernetes/api/v1/om" + "github.com/10gen/ops-manager-kubernetes/api/v1/status" + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/mock" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/workflow" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/stretchr/testify/assert" + "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +const OperatorNamespace = "operatorNs" + +func init() { + util.OperatorVersion = "9.9.9-test" + _ = os.Setenv(util.CurrentNamespace, OperatorNamespace) +} + +func TestEnsureTagAdded(t *testing.T) { + manager := mock.NewEmptyManager() + manager.Client.AddDefaultMdbConfigResources() + controller := newReconcileCommonController(manager) + mockOm, _ := prepareConnection(controller, om.NewEmptyMockedOmConnection, t) + + // normal tag + err := connection.EnsureTagAdded(mockOm, mockOm.FindGroup(om.TestGroupName), "myTag", zap.S()) + assert.NoError(t, err) + + // long tag + err = connection.EnsureTagAdded(mockOm, mockOm.FindGroup(om.TestGroupName), "LOOKATTHISTRINGTHATISTOOLONGFORTHEFIELD", zap.S()) + assert.NoError(t, err) + + expected := []string{"EXTERNALLY_MANAGED_BY_KUBERNETES", "MY-NAMESPACE", "MYTAG", "LOOKATTHISTRINGTHATISTOOLONGFORT"} + assert.Equal(t, expected, mockOm.FindGroup(om.TestGroupName).Tags) +} + +func TestEnsureTagAddedDuplicates(t *testing.T) { + manager := mock.NewEmptyManager() + manager.Client.AddDefaultMdbConfigResources() + opsManagerController := newReconcileCommonController(manager) + + mockOm, _ := prepareConnection(opsManagerController, om.NewEmptyMockedOmConnection, t) + err := connection.EnsureTagAdded(mockOm, mockOm.FindGroup(om.TestGroupName), "MYTAG", zap.S()) + assert.NoError(t, err) + err = connection.EnsureTagAdded(mockOm, mockOm.FindGroup(om.TestGroupName), "MYTAG", zap.S()) + assert.NoError(t, err) + err = connection.EnsureTagAdded(mockOm, mockOm.FindGroup(om.TestGroupName), "MYOTHERTAG", zap.S()) + assert.NoError(t, err) + expected := []string{"EXTERNALLY_MANAGED_BY_KUBERNETES", "MY-NAMESPACE", "MYTAG", "MYOTHERTAG"} + assert.Equal(t, expected, mockOm.FindGroup(om.TestGroupName).Tags) +} + +// TestPrepareOmConnection_FindExistingGroup finds existing group when org ID is specified, no new Project or Organization +// is created +func TestPrepareOmConnection_FindExistingGroup(t *testing.T) { + manager := mock.NewEmptyManager() + manager.Client.AddCredentialsSecret(om.TestUser, om.TestApiKey) + manager.Client.AddProjectConfigMap(om.TestGroupName, om.TestOrgID) + + controller := newReconcileCommonController(manager) + mockOm, _ := prepareConnection(controller, omConnGroupInOrganizationWithDifferentName(), t) + assert.Equal(t, "existing-group-id", mockOm.GroupID()) + // No new group was created + assert.Len(t, mockOm.OrganizationsWithGroups, 1) + + mockOm.CheckOrderOfOperations(t, reflect.ValueOf(mockOm.ReadOrganization), reflect.ValueOf(mockOm.ReadProjectsInOrganizationByName)) + mockOm.CheckOperationsDidntHappen(t, reflect.ValueOf(mockOm.ReadOrganizations), reflect.ValueOf(mockOm.CreateProject), reflect.ValueOf(mockOm.ReadProjectsInOrganization)) +} + +// TestPrepareOmConnection_DuplicatedGroups verifies that if there are groups with the same name but in different organization +// then the new group is created +func TestPrepareOmConnection_DuplicatedGroups(t *testing.T) { + manager := mock.NewEmptyManager() + manager.Client.AddDefaultMdbConfigResources() + + // The only difference from TestPrepareOmConnection_FindExistingGroup above is that the config map contains only project name + // but no org ID (see newMockedKubeApi()) + controller := newReconcileCommonController(manager) + + mockOm, _ := prepareConnection(controller, omConnGroupInOrganizationWithDifferentName(), t) + assert.Equal(t, om.TestGroupID, mockOm.GroupID()) + mockOm.CheckGroupInOrganization(t, om.TestGroupName, om.TestGroupName) + // New group and organization will be created in addition to existing ones + assert.Len(t, mockOm.OrganizationsWithGroups, 2) + + mockOm.CheckOrderOfOperations(t, reflect.ValueOf(mockOm.ReadOrganizationsByName), reflect.ValueOf(mockOm.CreateProject)) + mockOm.CheckOperationsDidntHappen(t, reflect.ValueOf(mockOm.ReadOrganizations), + reflect.ValueOf(mockOm.ReadProjectsInOrganization), reflect.ValueOf(mockOm.ReadProjectsInOrganizationByName)) +} + +// TestPrepareOmConnection_CreateGroup checks that if the group doesn't exist in OM - it is created +func TestPrepareOmConnection_CreateGroup(t *testing.T) { + manager := mock.NewEmptyManager() + manager.Client.AddDefaultMdbConfigResources() + controller := newReconcileCommonController(manager) + + mockOm, vars := prepareConnection(controller, om.NewEmptyMockedOmConnectionNoGroup, t) + + assert.Equal(t, om.TestGroupID, vars.ProjectID) + assert.Equal(t, om.TestGroupID, mockOm.GroupID()) + mockOm.CheckGroupInOrganization(t, om.TestGroupName, om.TestGroupName) + assert.Len(t, mockOm.OrganizationsWithGroups, 1) + assert.Contains(t, mockOm.FindGroup(om.TestGroupName).Tags, strings.ToUpper(mock.TestNamespace)) + + mockOm.CheckOrderOfOperations(t, reflect.ValueOf(mockOm.ReadOrganizationsByName), reflect.ValueOf(mockOm.CreateProject)) + mockOm.CheckOperationsDidntHappen(t, reflect.ValueOf(mockOm.ReadProjectsInOrganization)) +} + +// TestPrepareOmConnection_CreateGroupFixTags fixes tags if they are not set for existing group +func TestPrepareOmConnection_CreateGroupFixTags(t *testing.T) { + manager := mock.NewEmptyManager() + manager.Client.AddDefaultMdbConfigResources() + + controller := newReconcileCommonController(manager) + + mockOm, _ := prepareConnection(controller, omConnGroupWithoutTags(), t) + assert.Contains(t, mockOm.FindGroup(om.TestGroupName).Tags, strings.ToUpper(mock.TestNamespace)) + + mockOm.CheckOrderOfOperations(t, reflect.ValueOf(mockOm.UpdateProject)) +} + +func readAgentApiKeyForProject(client kubernetesClient.Client, namespace, agentKeySecretName string) (string, error) { + secret, err := client.GetSecret(kube.ObjectKey(namespace, agentKeySecretName)) + if err != nil { + return "", err + } + + key, ok := secret.Data[util.OmAgentApiKey] + if !ok { + return "", xerrors.Errorf("Could not find key \"%s\" in secret %s", util.OmAgentApiKey, agentKeySecretName) + } + + return strings.TrimSuffix(string(key), "\n"), nil +} + +// TestPrepareOmConnection_PrepareAgentKeys checks that agent key is generated and put to secret +func TestPrepareOmConnection_PrepareAgentKeys(t *testing.T) { + manager := mock.NewEmptyManager() + manager.Client.AddDefaultMdbConfigResources() + controller := newReconcileCommonController(manager) + + prepareConnection(controller, om.NewEmptyMockedOmConnection, t) + key, e := readAgentApiKeyForProject(controller.client, mock.TestNamespace, agents.ApiKeySecretName(om.TestGroupID)) + + assert.NoError(t, e) + // Unfortunately the key read is not equal to om.TestAgentKey - it's just some set of bytes. + // This is reproduced only in mocked tests - the production is fine (the key is real string) + // I assume that it's because when setting the secret data we use 'StringData' but read it back as + // 'Data' which is binary. May be real kubernetes api reads data as string and updates + assert.NotNil(t, key) + + manager.Client.CheckOrderOfOperations(t, + mock.HItem(reflect.ValueOf(manager.Client.Get), &corev1.Secret{}), + mock.HItem(reflect.ValueOf(manager.Client.Create), &corev1.Secret{})) +} + +// TestUpdateStatus_Patched makes sure that 'ReconcileCommonController.updateStatus()' changes only status for current +// object in Kubernetes and leaves spec unchanged +func TestUpdateStatus_Patched(t *testing.T) { + rs := DefaultReplicaSetBuilder().Build() + manager := mock.NewManager(rs) + controller := newReconcileCommonController(manager) + reconciledObject := rs.DeepCopy() + // The current reconciled object "has diverged" from the one in API server + reconciledObject.Spec.Version = "10.0.0" + _, err := controller.updateStatus(reconciledObject, workflow.Pending("Waiting for secret..."), zap.S()) + assert.NoError(t, err) + + // Verifying that the resource in API server still has correct spec + currentRs := mdbv1.MongoDB{} + assert.NoError(t, manager.Client.Get(context.Background(), rs.ObjectKey(), ¤tRs)) + + // The spec hasn't changed - only status + assert.Equal(t, rs.Spec, currentRs.Spec) + assert.Equal(t, status.PhasePending, currentRs.Status.Phase) + assert.Equal(t, "Waiting for secret...", currentRs.Status.Message) +} + +func TestReadSubjectFromJustCertificate(t *testing.T) { + assertSubjectFromFileSucceeds(t, "CN=mms-automation-agent,OU=MongoDB Kubernetes Operator,O=mms-automation-agent,L=NY,ST=NY,C=US", "testdata/certificates/just_certificate") +} + +func TestReadSubjectFromCertificateThenKey(t *testing.T) { + assertSubjectFromFileSucceeds(t, "CN=mms-automation-agent,OU=MongoDB Kubernetes Operator,O=mms-automation-agent,L=NY,ST=NY,C=US", "testdata/certificates/certificate_then_key") +} + +func TestReadSubjectFromKeyThenCertificate(t *testing.T) { + assertSubjectFromFileSucceeds(t, "CN=mms-automation-agent,OU=MongoDB Kubernetes Operator,O=mms-automation-agent,L=NY,ST=NY,C=US", "testdata/certificates/key_then_certificate") +} + +func TestReadSubjectFromCertInStrictlyRFC2253(t *testing.T) { + assertSubjectFromFileSucceeds(t, "CN=mms-agent-cert,O=MongoDB-agent,OU=TSE,L=New Delhi,ST=New Delhi,C=IN", "testdata/certificates/cert_rfc2253") +} + +func TestReadSubjectNoCertificate(t *testing.T) { + assertSubjectFromFileFails(t, "testdata/certificates/just_key") +} + +func TestDontSendNilPrivileges(t *testing.T) { + customRole := mdbv1.MongoDbRole{ + Role: "foo", + AuthenticationRestrictions: []mdbv1.AuthenticationRestriction{}, + Db: "admin", + Roles: []mdbv1.InheritedRole{{ + Db: "admin", + Role: "readWriteAnyDatabase", + }}, + } + assert.Nil(t, customRole.Privileges) + rs := DefaultReplicaSetBuilder().SetRoles([]mdbv1.MongoDbRole{customRole}).Build() + manager := mock.NewManager(rs) + manager.Client.AddDefaultMdbConfigResources() + controller := newReconcileCommonController(manager) + mockOm, _ := prepareConnection(controller, om.NewEmptyMockedOmConnection, t) + ensureRoles(rs.Spec.Security.Roles, mockOm, &zap.SugaredLogger{}) + ac, err := mockOm.ReadAutomationConfig() + assert.NoError(t, err) + roles, ok := ac.Deployment["roles"].([]mdbv1.MongoDbRole) + assert.True(t, ok) + assert.NotNil(t, roles[0].Privileges) +} + +func TestSecretWatcherWithAllResources(t *testing.T) { + caName := "custom-ca" + rs := DefaultReplicaSetBuilder().EnableTLS().EnableX509().SetTLSCA(caName).Build() + rs.Spec.Security.Authentication.InternalCluster = "X509" + manager := mock.NewManager(rs) + controller := newReconcileCommonController(manager) + + controller.SetupCommonWatchers(rs, nil, nil, rs.Name) + + // TODO: unify the watcher setup with the secret creation/mounting code in database creation + memberCert := rs.GetSecurity().MemberCertificateSecretName(rs.Name) + internalAuthCert := rs.GetSecurity().InternalClusterAuthSecretName(rs.Name) + + expected := map[watch.Object][]types.NamespacedName{ + {ResourceType: watch.ConfigMap, Resource: kube.ObjectKey(mock.TestNamespace, mock.TestProjectConfigMapName)}: {kube.ObjectKey(mock.TestNamespace, rs.Name)}, + {ResourceType: watch.ConfigMap, Resource: kube.ObjectKey(mock.TestNamespace, caName)}: {kube.ObjectKey(mock.TestNamespace, rs.Name)}, + {ResourceType: watch.Secret, Resource: kube.ObjectKey(mock.TestNamespace, rs.Spec.Credentials)}: {kube.ObjectKey(mock.TestNamespace, rs.Name)}, + {ResourceType: watch.Secret, Resource: kube.ObjectKey(mock.TestNamespace, memberCert)}: {kube.ObjectKey(mock.TestNamespace, rs.Name)}, + {ResourceType: watch.Secret, Resource: kube.ObjectKey(mock.TestNamespace, internalAuthCert)}: {kube.ObjectKey(mock.TestNamespace, rs.Name)}, + } + + assert.Equal(t, expected, controller.WatchedResources) +} + +func TestSecretWatcherWithSelfProvidedTLSSecretNames(t *testing.T) { + caName := "custom-ca" + + rs := DefaultReplicaSetBuilder().EnableTLS().EnableX509().SetTLSCA(caName).Build() + manager := mock.NewManager(rs) + controller := newReconcileCommonController(manager) + + controller.SetupCommonWatchers(rs, func() []string { + return []string{"a-secret"} + }, nil, rs.Name) + + expected := map[watch.Object][]types.NamespacedName{ + {ResourceType: watch.ConfigMap, Resource: kube.ObjectKey(mock.TestNamespace, mock.TestProjectConfigMapName)}: {kube.ObjectKey(mock.TestNamespace, rs.Name)}, + {ResourceType: watch.ConfigMap, Resource: kube.ObjectKey(mock.TestNamespace, caName)}: {kube.ObjectKey(mock.TestNamespace, rs.Name)}, + {ResourceType: watch.Secret, Resource: kube.ObjectKey(mock.TestNamespace, rs.Spec.Credentials)}: {kube.ObjectKey(mock.TestNamespace, rs.Name)}, + {ResourceType: watch.Secret, Resource: kube.ObjectKey(mock.TestNamespace, "a-secret")}: {kube.ObjectKey(mock.TestNamespace, rs.Name)}, + } + + assert.Equal(t, expected, controller.WatchedResources) +} + +func assertSubjectFromFileFails(t *testing.T, filePath string) { + assertSubjectFromFile(t, "", filePath, false) +} + +func assertSubjectFromFileSucceeds(t *testing.T, expectedSubject, filePath string) { + assertSubjectFromFile(t, expectedSubject, filePath, true) +} + +func assertSubjectFromFile(t *testing.T, expectedSubject, filePath string, passes bool) { + data, err := ioutil.ReadFile(filePath) + assert.NoError(t, err) + subject, err := getSubjectFromCertificate(string(data)) + if passes { + assert.NoError(t, err) + } else { + assert.Error(t, err) + } + assert.Equal(t, expectedSubject, subject) +} + +func prepareConnection(controller *ReconcileCommonController, omConnectionFunc om.ConnectionFactory, t *testing.T) (*om.MockedOmConnection, *env.PodEnvVars) { + + projectConfig, err := project.ReadProjectConfig(controller.client, kube.ObjectKey(mock.TestNamespace, mock.TestProjectConfigMapName), "mdb-name") + assert.NoError(t, err) + credsConfig, err := project.ReadCredentials(controller.SecretClient, kube.ObjectKey(mock.TestNamespace, mock.TestCredentialsSecretName), &zap.SugaredLogger{}) + assert.NoError(t, err) + + spec := mdbv1.ConnectionSpec{ + SharedConnectionSpec: mdbv1.SharedConnectionSpec{ + OpsManagerConfig: &mdbv1.PrivateCloudConfig{ + ConfigMapRef: mdbv1.ConfigMapRef{ + Name: mock.TestProjectConfigMapName, + }, + }, + LogLevel: mdbv1.Warn, + }, + Credentials: mock.TestCredentialsSecretName, + } + + conn, e := connection.PrepareOpsManagerConnection(controller.SecretClient, projectConfig, credsConfig, omConnectionFunc, mock.TestNamespace, zap.S()) + mockOm := conn.(*om.MockedOmConnection) + assert.NoError(t, e) + return mockOm, newPodVars(conn, projectConfig, spec) +} + +func omConnGroupWithoutTags() om.ConnectionFactory { + return func(ctx *om.OMContext) om.Connection { + c := om.NewEmptyMockedOmConnectionNoGroup(ctx).(*om.MockedOmConnection) + if len(c.OrganizationsWithGroups) == 0 { + // initially OM contains the group without tags + c.OrganizationsWithGroups = map[*om.Organization][]*om.Project{{ID: om.TestOrgID, Name: om.TestGroupName}: {{Name: om.TestGroupName, ID: "123", AgentAPIKey: "12345abcd", OrgID: om.TestOrgID}}} + } + return c + } +} + +func omConnGroupInOrganizationWithDifferentName() om.ConnectionFactory { + return func(omContext *om.OMContext) om.Connection { + c := om.NewEmptyMockedOmConnectionNoGroup(omContext).(*om.MockedOmConnection) + if len(c.OrganizationsWithGroups) == 0 { + // Important: the organization for the group has a different name ("foo") then group (om.TestGroupName). + // So it won't work for cases when the group "was created before" by Operator + c.OrganizationsWithGroups = map[*om.Organization][]*om.Project{{ID: om.TestOrgID, Name: "foo"}: {{Name: om.TestGroupName, ID: "existing-group-id", OrgID: om.TestOrgID}}} + } + + return c + } +} + +func requestFromObject(object metav1.Object) reconcile.Request { + return reconcile.Request{NamespacedName: mock.ObjectKeyFromApiObject(object)} +} + +func testConnectionSpec() mdbv1.ConnectionSpec { + return mdbv1.ConnectionSpec{ + SharedConnectionSpec: mdbv1.SharedConnectionSpec{ + OpsManagerConfig: &mdbv1.PrivateCloudConfig{ + ConfigMapRef: mdbv1.ConfigMapRef{ + Name: mock.TestProjectConfigMapName, + }, + }, + }, + Credentials: mock.TestCredentialsSecretName, + } +} + +func checkReconcileSuccessful(t *testing.T, reconciler reconcile.Reconciler, object *mdbv1.MongoDB, client *mock.MockedClient) { + result, e := reconciler.Reconcile(context.TODO(), requestFromObject(object)) + require.NoError(t, e) + require.Equal(t, reconcile.Result{}, result) + + // also need to make sure the object status is updated to successful + assert.NoError(t, client.Get(context.TODO(), mock.ObjectKeyFromApiObject(object), object)) + assert.Equal(t, status.PhaseRunning, object.Status.Phase) + + expectedLink := deployment.Link(om.TestURL, om.TestGroupID) + + // fields common to all resource types + assert.Equal(t, object.Spec.Version, object.Status.Version) + assert.Equal(t, expectedLink, object.Status.Link) + assert.NotNil(t, object.Status.LastTransition) + assert.NotEqual(t, object.Status.LastTransition, "") + + assert.Equal(t, object.GetGeneration(), object.Status.ObservedGeneration) + + switch object.Spec.ResourceType { + case mdbv1.ReplicaSet: + assert.Equal(t, object.Spec.Members, object.Status.Members) + case mdbv1.ShardedCluster: + assert.Equal(t, object.Spec.ConfigServerCount, object.Status.ConfigServerCount) + assert.Equal(t, object.Spec.MongosCount, object.Status.MongosCount) + assert.Equal(t, object.Spec.MongodsPerShardCount, object.Status.MongodsPerShardCount) + assert.Equal(t, object.Spec.ShardCount, object.Status.ShardCount) + } +} + +func checkOMReconciliationSuccessful(t *testing.T, reconciler reconcile.Reconciler, om *omv1.MongoDBOpsManager) { + res, err := reconciler.Reconcile(context.TODO(), requestFromObject(om)) + expected := reconcile.Result{Requeue: true} + assert.Equal(t, expected, res) + assert.NoError(t, err) + + res, err = reconciler.Reconcile(context.TODO(), requestFromObject(om)) + expected = reconcile.Result{} + assert.Equal(t, expected, res) + assert.NoError(t, err) +} + +func checkOMReconciliationPending(t *testing.T, reconciler reconcile.Reconciler, om *omv1.MongoDBOpsManager) { + res, err := reconciler.Reconcile(context.TODO(), requestFromObject(om)) + assert.NoError(t, err) + assert.True(t, res.Requeue || res.RequeueAfter == time.Duration(10000000000)) +} + +func checkReconcileFailed(t *testing.T, reconciler reconcile.Reconciler, object *mdbv1.MongoDB, expectedRetry bool, expectedErrorMessage string, client *mock.MockedClient) { + failedResult := reconcile.Result{} + if expectedRetry { + failedResult.RequeueAfter = 10 * time.Second + } + result, e := reconciler.Reconcile(context.TODO(), requestFromObject(object)) + assert.Nil(t, e, "When retrying, error should be nil") + assert.Equal(t, failedResult, result) + + // also need to make sure the object status is updated to failed + assert.NoError(t, client.Get(context.TODO(), mock.ObjectKeyFromApiObject(object), object)) + assert.Equal(t, status.PhaseFailed, object.Status.Phase) + assert.Contains(t, object.Status.Message, expectedErrorMessage) +} + +func checkReconcilePending(t *testing.T, reconciler reconcile.Reconciler, object *mdbv1.MongoDB, expectedErrorMessage string, client *mock.MockedClient, requeueAfter time.Duration) { + failedResult := reconcile.Result{RequeueAfter: requeueAfter * time.Second} + result, e := reconciler.Reconcile(context.TODO(), requestFromObject(object)) + assert.Nil(t, e, "When pending, error should be nil") + assert.Equal(t, failedResult, result) + + // also need to make sure the object status is updated to failed + assert.NoError(t, client.Get(context.TODO(), mock.ObjectKeyFromApiObject(object), object)) + assert.Equal(t, status.PhasePending, object.Status.Phase) + assert.Contains(t, object.Status.Message, expectedErrorMessage) +} + +func getWatch(namespace string, resourceName string, t watch.Type) watch.Object { + configSecret := watch.Object{ + ResourceType: t, + Resource: types.NamespacedName{ + Namespace: namespace, + Name: resourceName, + }, + } + return configSecret +} diff --git a/controllers/operator/connection/opsmanager_connection.go b/controllers/operator/connection/opsmanager_connection.go new file mode 100644 index 000000000..eccf19f3d --- /dev/null +++ b/controllers/operator/connection/opsmanager_connection.go @@ -0,0 +1,76 @@ +package connection + +import ( + "fmt" + "strings" + + "golang.org/x/xerrors" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/agents" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/controlledfeature" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/project" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/secrets" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" + "go.uber.org/zap" +) + +func PrepareOpsManagerConnection(client secrets.SecretClient, projectConfig mdbv1.ProjectConfig, credentials mdbv1.Credentials, connectionFunc om.ConnectionFactory, namespace string, log *zap.SugaredLogger) (om.Connection, error) { + omProject, conn, err := project.ReadOrCreateProject(projectConfig, credentials, connectionFunc, log) + if err != nil { + return nil, xerrors.Errorf("error reading or creating project in Ops Manager: %w", err) + } + + omVersion := conn.OpsManagerVersion() + if omVersion.VersionString != "" { // older versions of Ops Manager will not include the version in the header + log.Infof("Using Ops Manager version %s", omVersion) + } + + // adds the namespace as a tag to the Ops Manager project + if err = EnsureTagAdded(conn, omProject, namespace, log); err != nil { + return nil, err + } + + // adds the externally_managed tag if feature controls is not available. + if !controlledfeature.ShouldUseFeatureControls(conn.OpsManagerVersion()) { + if err = EnsureTagAdded(conn, omProject, util.OmGroupExternallyManagedTag, log); err != nil { + return nil, err + } + } + + var databaseSecretPath string + if client.VaultClient != nil { + databaseSecretPath = client.VaultClient.DatabaseSecretPath() + } + // TODO: we may want to remove this from this function in the future, this is not strictly related + // to establishing an Ops Manager connection + if err = agents.EnsureAgentKeySecretExists(client, conn, namespace, omProject.AgentAPIKey, conn.GroupID(), databaseSecretPath, log); err != nil { + return nil, err + } + return conn, nil +} + +// EnsureTagAdded makes sure that the given project has the provided tag +func EnsureTagAdded(conn om.Connection, project *om.Project, tag string, log *zap.SugaredLogger) error { + // must truncate the tag to at most 32 characters and capitalise as + // these are Ops Manager requirements + + sanitisedTag := strings.ToUpper(fmt.Sprintf("%.32s", tag)) + alreadyHasTag := stringutil.Contains(project.Tags, sanitisedTag) + if alreadyHasTag { + return nil + } + + project.Tags = append(project.Tags, sanitisedTag) + + log.Infow("Updating group tags", "newTags", project.Tags) + _, err := conn.UpdateProject(project) + if err != nil { + log.Warnf("Failed to update tags for project: %s", err) + } else { + log.Info("Project tags are fixed") + } + return err +} diff --git a/controllers/operator/connectionstring/connectionstring.go b/controllers/operator/connectionstring/connectionstring.go new file mode 100644 index 000000000..03ebb5d65 --- /dev/null +++ b/controllers/operator/connectionstring/connectionstring.go @@ -0,0 +1,220 @@ +// Presents a builder to programmatically build a MongoDB connection string. +// +// We are waiting for a more consistent solution to this, based on a +// ConnString structure. +// +// https://jira.mongodb.org/browse/GODRIVER-2226 + +package connectionstring + +import ( + "fmt" + "net/url" + "sort" + "strings" + + "github.com/10gen/ops-manager-kubernetes/pkg/dns" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" +) + +type ConnectionStringBuilder interface { + BuildConnectionString(userName, password string, scheme Scheme, connectionParams map[string]string) string +} + +// Scheme states the connection string format. +// https://docs.mongodb.com/manual/reference/connection-string/#connection-string-formats +type Scheme string + +const ( + SchemeMongoDB Scheme = "mongodb" + SchemeMongoDBSRV Scheme = "mongodb+srv" +) + +// builder is a struct that we'll use to build a connection string. +type builder struct { + name string + namespace string + + username string + password string + replicas int + port int32 + service string + version string + + authenticationModes []string + clusterDomain string + isReplicaSet bool + isTLSEnabled bool + + multiClusterHosts []string + + scheme Scheme + connectionParams map[string]string +} + +func (b *builder) SetName(name string) *builder { + b.name = name + return b +} + +func (b *builder) SetNamespace(namespace string) *builder { + b.namespace = namespace + return b +} + +func (b *builder) SetUsername(username string) *builder { + b.username = username + return b +} + +func (b *builder) SetPassword(password string) *builder { + b.password = password + return b +} + +func (b *builder) SetReplicas(replicas int) *builder { + b.replicas = replicas + return b +} + +func (b *builder) SetService(service string) *builder { + b.service = service + return b +} + +func (b *builder) SetPort(port int32) *builder { + b.port = port + return b +} + +func (b *builder) SetVersion(version string) *builder { + b.version = version + return b +} + +func (b *builder) SetAuthenticationModes(authenticationModes []string) *builder { + b.authenticationModes = authenticationModes + return b +} + +func (b *builder) SetClusterDomain(clusterDomain string) *builder { + b.clusterDomain = clusterDomain + return b +} + +func (b *builder) SetIsReplicaSet(isReplicaSet bool) *builder { + b.isReplicaSet = isReplicaSet + return b +} + +func (b *builder) SetIsTLSEnabled(isTLSEnabled bool) *builder { + b.isTLSEnabled = isTLSEnabled + return b +} + +func (b *builder) SetMultiClusterHosts(multiClusterHosts []string) *builder { + b.multiClusterHosts = multiClusterHosts + return b +} + +func (b *builder) SetScheme(scheme Scheme) *builder { + b.scheme = scheme + return b +} + +func (b *builder) SetConnectionParams(cParams map[string]string) *builder { + for key, value := range cParams { + b.connectionParams[key] = value + } + return b +} + +// Build builds a new connection string from the builder. +func (b builder) Build() string { + var userAuth string + if stringutil.Contains(b.authenticationModes, util.SCRAM) && + b.username != "" && b.password != "" { + + userAuth = fmt.Sprintf("%s:%s@", url.QueryEscape(b.username), url.QueryEscape(b.password)) + } + + var uri string + if b.scheme == SchemeMongoDBSRV { + uri = fmt.Sprintf("mongodb+srv://%s", userAuth) + uri += fmt.Sprintf("%s.%s.svc.%s", b.service, b.namespace, b.clusterDomain) + } else { + uri = fmt.Sprintf("mongodb://%s", userAuth) + var hostnames []string + if len(b.multiClusterHosts) > 0 { + hostnames = b.multiClusterHosts + } else { + hostnames, _ = dns.GetDNSNames(b.name, b.service, b.namespace, b.clusterDomain, b.replicas, nil) + for i, h := range hostnames { + hostnames[i] = fmt.Sprintf("%s:%d", h, b.port) + } + } + uri += strings.Join(hostnames, ",") + } + + connectionParams := make(map[string]string) + if b.isReplicaSet { + connectionParams["replicaSet"] = b.name + } + if b.isTLSEnabled { + connectionParams["ssl"] = "true" + } + + authSource, authMechanism := authSourceAndMechanism(b.authenticationModes, b.version) + if authSource != "" && authMechanism != "" { + connectionParams["authSource"] = authSource + connectionParams["authMechanism"] = authMechanism + } + + // Merge received (b.connectionParams) on top of local (connectionParams) + // Make sure that received parameters have priority. + for k, v := range b.connectionParams { + connectionParams[k] = v + } + var keys []string + for k := range connectionParams { + keys = append(keys, k) + } + uri += "/?" + + // sorting parameters to make a url stable + sort.Strings(keys) + for _, k := range keys { + uri += fmt.Sprintf("%s=%s&", k, connectionParams[k]) + } + return strings.TrimSuffix(uri, "&") +} + +func Builder() *builder { + return &builder{ + port: util.MongoDbDefaultPort, + connectionParams: map[string]string{"connectTimeoutMS": "20000", "serverSelectionTimeoutMS": "20000"}, + } +} + +// authSourceAndMechanism returns AuthSource and AuthMechanism. +func authSourceAndMechanism(authenticationModes []string, version string) (string, string) { + var authSource string + var authMechanism string + if stringutil.Contains(authenticationModes, util.SCRAM) { + authSource = util.DefaultUserDatabase + + comparison, err := util.CompareVersions(version, util.MinimumScramSha256MdbVersion) + if err != nil { + return "", "" + } + if comparison < 0 { + authMechanism = "SCRAM-SHA-1" + } else { + authMechanism = "SCRAM-SHA-256" + } + } + + return authSource, authMechanism +} diff --git a/controllers/operator/construct/appdb_construction.go b/controllers/operator/construct/appdb_construction.go new file mode 100644 index 000000000..3896389a9 --- /dev/null +++ b/controllers/operator/construct/appdb_construction.go @@ -0,0 +1,627 @@ +package construct + +import ( + "fmt" + "os" + "path" + "regexp" + "strconv" + "strings" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/util/envvar" + + "go.uber.org/zap" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/agents" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/certs" + "github.com/10gen/ops-manager-kubernetes/pkg/tls" + "github.com/10gen/ops-manager-kubernetes/pkg/vault" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/api/v1/om" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/util/scale" + + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" + "github.com/mongodb/mongodb-kubernetes-operator/controllers/construct" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/container" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/podtemplatespec" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/statefulset" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/util/merge" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" +) + +const ( + appDBServiceAccount = "mongodb-enterprise-appdb" + InitAppDbContainerName = "mongodb-enterprise-init-appdb" + // AppDB environment variable names + initAppdbVersionEnv = "INIT_APPDB_VERSION" + podNamespaceEnv = "POD_NAMESPACE" + automationConfigMapEnv = "AUTOMATION_CONFIG_MAP" + headlessAgentEnv = "HEADLESS_AGENT" + monitoringAgentContainerName = "mongodb-agent-monitoring" + // Since the Monitoring Agent is created based on Agent's Pod spec (we modfy it using addMonitoringContainer), + // We can not reuse "tmp" here - this name is already taken and could lead to a clash. It's better to + // come up with a unique name here. + tmpSubpathName = "mongodb-agent-monitoring-tmp" +) + +type AppDBStatefulSetOptions struct { + VaultConfig vault.VaultConfiguration + CertHash string + MonitoringAgentVersion string + + PrometheusTLSCertHash string +} + +func WithAppDBVaultConfig(config vault.VaultConfiguration) func(opts *AppDBStatefulSetOptions) { + return func(opts *AppDBStatefulSetOptions) { + opts.VaultConfig = config + } +} + +// getContainerIndexByName returns the index of a container with the given name in a slice of containers. +// It returns -1 if it doesn't exist +func getContainerIndexByName(containers []corev1.Container, name string) int { + for i, container := range containers { + if container.Name == name { + return i + } + } + return -1 +} + +// removeContainerByName removes the container with the given name from the input slice, if it exists. +func removeContainerByName(containers []corev1.Container, name string) []corev1.Container { + index := getContainerIndexByName(containers, name) + if index == -1 { + return containers + } + return append(containers[:index], containers[index+1:]...) +} + +// appDbLabels returns a statefulset modification which adds labels that are specific to the appDB. +func appDbLabels(opsManager om.MongoDBOpsManager) statefulset.Modification { + podLabels := map[string]string{ + appLabelKey: opsManager.Spec.AppDB.ServiceName(), + ControllerLabelName: util.OperatorName, + PodAntiAffinityLabelKey: opsManager.Spec.AppDB.Name(), + } + return statefulset.Apply( + statefulset.WithLabels(opsManager.Labels), + statefulset.WithMatchLabels(podLabels), + statefulset.WithPodSpecTemplate( + podtemplatespec.Apply( + podtemplatespec.WithPodLabels(podLabels), + ), + ), + ) +} + +// appDbPodSpec return the podtemplatespec modification required for the AppDB statefulset. +func appDbPodSpec(appDb om.AppDBSpec) podtemplatespec.Modification { + + // The following sets almost the exact same values for the containers + // But with the addition of a default memory request for the mongod one + appdbPodSpec := NewDefaultPodSpecWrapper(*appDb.PodSpec) + mongoPodSpec := *appdbPodSpec + mongoPodSpec.Default.MemoryRequests = util.DefaultMemoryAppDB + mongoPodTemplateFunc := podtemplatespec.WithContainer( + construct.MongodbName, + container.WithResourceRequirements(buildRequirementsFromPodSpec(mongoPodSpec)), + ) + automationPodTemplateFunc := podtemplatespec.WithContainer( + construct.AgentName, + container.WithResourceRequirements(buildRequirementsFromPodSpec(*appdbPodSpec)), + ) + + // the appdb will have a single init container, + // all the necessary binaries will be copied into the various + // volumes of different containers. + updateInitContainers := func(templateSpec *corev1.PodTemplateSpec) { + templateSpec.Spec.InitContainers = []corev1.Container{} + scriptsVolumeMount := statefulset.CreateVolumeMount("agent-scripts", "/opt/scripts", statefulset.WithReadOnly(false)) + hooksVolumeMount := statefulset.CreateVolumeMount("hooks", "/hooks", statefulset.WithReadOnly(false)) + podtemplatespec.WithInitContainer(InitAppDbContainerName, buildAppDBInitContainer([]corev1.VolumeMount{scriptsVolumeMount, hooksVolumeMount}))(templateSpec) + } + + return podtemplatespec.Apply( + mongoPodTemplateFunc, + automationPodTemplateFunc, + updateInitContainers, + ) +} + +// buildAppDBInitContainer builds the container specification for mongodb-enterprise-init-appdb image. +func buildAppDBInitContainer(volumeMounts []corev1.VolumeMount) container.Modification { + version := env.ReadOrDefault(initAppdbVersionEnv, "latest") + initContainerImageURL := ContainerImage(util.InitAppdbImageUrlEnv, version, nil) + + return container.Apply( + container.WithName(InitAppDbContainerName), + container.WithImage(initContainerImageURL), + container.WithCommand([]string{"/bin/sh", "-c", ` + +# the agent requires the readiness probe +cp /probes/readinessprobe /opt/scripts/readinessprobe + +# the mongod requires the version upgrade hook +cp /probes/version-upgrade-hook /hooks/version-upgrade +`}), + container.WithVolumeMounts(volumeMounts), + ) +} + +// getTLSVolumesAndVolumeMounts returns the slices of volumes and volume-mounts +// that the AppDB STS needs for TLS resources. +func getTLSVolumesAndVolumeMounts(appDb om.AppDBSpec, podVars *env.PodEnvVars, log *zap.SugaredLogger) ([]corev1.Volume, []corev1.VolumeMount) { + if log == nil { + log = zap.S() + } + var volumesToAdd []corev1.Volume + var volumeMounts []corev1.VolumeMount + + if podVars != nil && podVars.SSLMMSCAConfigMap != "" { + // This volume wil contain the OM CA + caCertVolume := statefulset.CreateVolumeFromConfigMap(CaCertName, podVars.SSLMMSCAConfigMap) + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + MountPath: caCertMountPath, + Name: caCertVolume.Name, + ReadOnly: true, + }) + volumesToAdd = append(volumesToAdd, caCertVolume) + } + + tlsConfig := appDb.GetTLSConfig() + secretName := appDb.GetSecurity().MemberCertificateSecretName(appDb.Name()) + + secretName += certs.OperatorGeneratedCertSuffix + optionalSecretFunc := func(v *corev1.Volume) { v.Secret.Optional = util.BooleanRef(true) } + optionalConfigMapFunc := func(v *corev1.Volume) { v.ConfigMap.Optional = util.BooleanRef(true) } + + if !vault.IsVaultSecretBackend() { + secretVolume := statefulset.CreateVolumeFromSecret(util.SecretVolumeName, secretName, optionalSecretFunc) + + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + MountPath: util.SecretVolumeMountPath + "/certs", + Name: secretVolume.Name, + ReadOnly: true, + }) + volumesToAdd = append(volumesToAdd, secretVolume) + } + caName := fmt.Sprintf("%s-ca", appDb.Name()) + + if tlsConfig.CA != "" { + caName = tlsConfig.CA + } else { + log.Debugf("No CA name has been supplied, defaulting to: %s", caName) + } + + caVolume := statefulset.CreateVolumeFromConfigMap(tls.ConfigMapVolumeCAName, caName, optionalConfigMapFunc) + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + MountPath: util.ConfigMapVolumeCAMountPath, + Name: caVolume.Name, + ReadOnly: true, + }) + volumesToAdd = append(volumesToAdd, caVolume) + + prometheusVolumes, prometheusVolumeMounts := getTLSPrometheusVolumeAndVolumeMount(appDb.Prometheus) + volumesToAdd = append(volumesToAdd, prometheusVolumes...) + volumeMounts = append(volumeMounts, prometheusVolumeMounts...) + + return volumesToAdd, volumeMounts +} + +// tlsVolumes returns the podtemplatespec modification that adds all needed volumes +// and volumemounts for TLS. +func tlsVolumes(appDb om.AppDBSpec, podVars *env.PodEnvVars, log *zap.SugaredLogger) podtemplatespec.Modification { + + volumesToAdd, volumeMounts := getTLSVolumesAndVolumeMounts(appDb, podVars, log) + volumesFunc := func(spec *corev1.PodTemplateSpec) { + for _, v := range volumesToAdd { + podtemplatespec.WithVolume(v)(spec) + } + } + + return podtemplatespec.Apply( + volumesFunc, + podtemplatespec.WithContainer( + construct.AgentName, + container.WithVolumeMounts(volumeMounts), + ), + podtemplatespec.WithContainer( + construct.MongodbName, + container.WithVolumeMounts(volumeMounts), + ), + ) +} + +func vaultModification(appDB om.AppDBSpec, podVars *env.PodEnvVars, opts AppDBStatefulSetOptions) podtemplatespec.Modification { + modification := podtemplatespec.NOOP() + if vault.IsVaultSecretBackend() { + appDBSecretsToInject := vault.AppDBSecretsToInject{Config: opts.VaultConfig} + if podVars != nil && podVars.ProjectID != "" { + appDBSecretsToInject.AgentApiKey = agents.ApiKeySecretName(podVars.ProjectID) + } + if appDB.GetSecurity().IsTLSEnabled() { + secretName := appDB.GetSecurity().MemberCertificateSecretName(appDB.Name()) + certs.OperatorGeneratedCertSuffix + appDBSecretsToInject.TLSSecretName = secretName + appDBSecretsToInject.TLSClusterHash = opts.CertHash + } + + appDBSecretsToInject.AutomationConfigSecretName = appDB.AutomationConfigSecretName() + appDBSecretsToInject.AutomationConfigPath = util.AppDBAutomationConfigKey + appDBSecretsToInject.AgentType = "automation-agent" + + if appDB.Prometheus != nil && appDB.Prometheus.TLSSecretRef.Name != "" && opts.PrometheusTLSCertHash != "" { + appDBSecretsToInject.PrometheusTLSCertHash = opts.PrometheusTLSCertHash + appDBSecretsToInject.PrometheusTLSPath = fmt.Sprintf("%s%s", appDB.Prometheus.TLSSecretRef.Name, certs.OperatorGeneratedCertSuffix) + } + + modification = podtemplatespec.Apply( + modification, + podtemplatespec.WithAnnotations(appDBSecretsToInject.AppDBAnnotations(appDB.Namespace)), + ) + + } else { + if podVars != nil && podVars.ProjectID != "" { + // AGENT-API-KEY volume + modification = podtemplatespec.Apply( + modification, + podtemplatespec.WithVolume(statefulset.CreateVolumeFromSecret(AgentAPIKeyVolumeName, agents.ApiKeySecretName(podVars.ProjectID))), + ) + } + } + return modification +} + +// customPersistenceConfig applies to the statefulset the modifications +// provided by the user through spec.persistence. +func customPersistenceConfig(om om.MongoDBOpsManager) statefulset.Modification { + defaultPodSpecPersistence := newDefaultPodSpec().Persistence + // Two main branches - as the user can either define a single volume for data, logs and journal + // or three different volumes + if !om.Spec.AppDB.HasSeparateDataAndLogsVolumes() { + var config *mdbv1.PersistenceConfig + if om.Spec.AppDB.PodSpec.Persistence != nil && om.Spec.AppDB.PodSpec.Persistence.SingleConfig != nil { + config = om.Spec.AppDB.PodSpec.Persistence.SingleConfig + } + // Single persistence, needs to modify the only pvc we have + pvcModification := pvcFunc(om.Spec.AppDB.DataVolumeName(), config, *defaultPodSpecPersistence.SingleConfig, om.Labels) + + // We already have, by default, the data volume mount, + // here we also create the logs and journal one, as subpath from the same volume + logsVolumeMount := statefulset.CreateVolumeMount(om.Spec.AppDB.DataVolumeName(), util.PvcMountPathLogs, statefulset.WithSubPath(om.Spec.AppDB.LogsVolumeName())) + journalVolumeMount := statefulset.CreateVolumeMount(om.Spec.AppDB.DataVolumeName(), util.PvcMountPathJournal, statefulset.WithSubPath(util.PvcNameJournal)) + volumeMounts := []corev1.VolumeMount{journalVolumeMount, logsVolumeMount} + return statefulset.Apply( + statefulset.WithVolumeClaim(om.Spec.AppDB.DataVolumeName(), pvcModification), + statefulset.WithPodSpecTemplate( + podtemplatespec.Apply( + podtemplatespec.WithContainer(construct.AgentName, + container.WithVolumeMounts(volumeMounts), + ), + podtemplatespec.WithContainer(construct.MongodbName, + container.WithVolumeMounts(volumeMounts), + ), + ), + ), + ) + + } else { + // Here need to modify data and logs volumes, + // and create the journal one (which doesn't exist in Community, where this original STS is built) + dataModification := pvcFunc(om.Spec.AppDB.DataVolumeName(), om.Spec.AppDB.PodSpec.Persistence.MultipleConfig.Data, *defaultPodSpecPersistence.MultipleConfig.Data, om.Labels) + logsModification := pvcFunc(om.Spec.AppDB.LogsVolumeName(), om.Spec.AppDB.PodSpec.Persistence.MultipleConfig.Logs, *defaultPodSpecPersistence.MultipleConfig.Logs, om.Labels) + + journalVolumeMounts := statefulset.CreateVolumeMount(util.PvcNameJournal, util.PvcMountPathJournal) + journalVolumeClaim := pvcFunc(util.PvcNameJournal, om.Spec.AppDB.PodSpec.Persistence.MultipleConfig.Journal, *defaultPodSpecPersistence.MultipleConfig.Journal, om.Labels) + + return statefulset.Apply( + statefulset.WithVolumeClaim(util.PvcMountPathLogs, journalVolumeClaim), + statefulset.WithVolumeClaim(om.Spec.AppDB.DataVolumeName(), dataModification), + statefulset.WithVolumeClaim(om.Spec.AppDB.LogsVolumeName(), logsModification), + statefulset.WithPodSpecTemplate( + podtemplatespec.Apply( + podtemplatespec.WithContainer(construct.AgentName, + container.WithVolumeMounts([]corev1.VolumeMount{journalVolumeMounts}), + ), + podtemplatespec.WithContainer(construct.MongodbName, + container.WithVolumeMounts([]corev1.VolumeMount{journalVolumeMounts}), + ), + ), + ), + ) + } +} + +// AppDbStatefulSet fully constructs the AppDb StatefulSet that is ready to be sent to the Kubernetes API server. +func AppDbStatefulSet(opsManager om.MongoDBOpsManager, podVars *env.PodEnvVars, opts AppDBStatefulSetOptions, log *zap.SugaredLogger) (appsv1.StatefulSet, error) { + appDb := &opsManager.Spec.AppDB + + // If we can enable monitoring, let's fill in container modification function + monitoringModification := podtemplatespec.NOOP() + monitorAppDB := env.ReadBoolOrDefault(util.OpsManagerMonitorAppDB, util.OpsManagerMonitorAppDBDefault) + if monitorAppDB && podVars != nil && podVars.ProjectID != "" { + monitoringModification = addMonitoringContainer(*appDb, *podVars, opts, log) + } else { + // Otherwise, let's remove for now every podTemplateSpec related to monitoring + // We will apply them when enabling monitoring + if appDb.PodSpec != nil && appDb.PodSpec.PodTemplateWrapper.PodTemplate != nil { + appDb.PodSpec.PodTemplateWrapper.PodTemplate.Spec.Containers = removeContainerByName(appDb.PodSpec.PodTemplateWrapper.PodTemplate.Spec.Containers, monitoringAgentContainerName) + } + } + + // We copy the Automation Agent command from community and add the agent startup parameters + automationAgentCommand := construct.AutomationAgentCommand() + idx := len(automationAgentCommand) - 1 + automationAgentCommand[idx] += appDb.AutomationAgent.StartupParameters.ToCommandLineArgs() + + acVersionConfigMapVolume := statefulset.CreateVolumeFromConfigMap("automation-config-goal-version", opsManager.Spec.AppDB.AutomationConfigConfigMapName()) + acVersionMount := corev1.VolumeMount{ + Name: acVersionConfigMapVolume.Name, + ReadOnly: true, + MountPath: "/var/lib/automation/config/acVersion", + } + + sts := statefulset.New( + // create appdb statefulset from the community code + construct.BuildMongoDBReplicaSetStatefulSetModificationFunction(&opsManager.Spec.AppDB, &opsManager), + // If run in certified openshift bundle in disconnected environment with digest pinning we need to update + // mongod image as it is constructed from 2 env variables and version from spec, and it will not be replaced to sha256 digest properly. + // The official image provides both CMD and ENTRYPOINT. We're reusing the former and need to replace + // the latter with an empty string. + containerImageModification(construct.MongodbName, getAppDBImage(opsManager.Spec.AppDB.Version), []string{""}), + // we don't need to update here the automation agent image for digest pinning, because it is defined in AGENT_IMAGE env var as full url with version + // if we run in certified bundle with digest pinning it will be properly updated to digest + customPersistenceConfig(opsManager), + statefulset.WithUpdateStrategyType(opsManager.GetAppDBUpdateStrategyType()), + statefulset.WithOwnerReference(kube.BaseOwnerReference(&opsManager)), + statefulset.WithReplicas(scale.ReplicasThisReconciliation(&opsManager)), + statefulset.WithPodSpecTemplate( + podtemplatespec.Apply( + podtemplatespec.WithServiceAccount(appDBServiceAccount), + podtemplatespec.WithVolume(acVersionConfigMapVolume), + podtemplatespec.WithContainer(construct.AgentName, + container.Apply( + container.WithCommand(automationAgentCommand), + container.WithEnvs(appdbContainerEnv(*appDb, podVars)...), + container.WithVolumeMounts([]corev1.VolumeMount{acVersionMount}), + ), + ), + vaultModification(*appDb, podVars, opts), + appDbPodSpec(*appDb), + monitoringModification, + tlsVolumes(*appDb, podVars, log), + ), + ), + appDbLabels(opsManager), + ) + // We merge the podspec specified in the CR + if appDb.PodSpec != nil && appDb.PodSpec.PodTemplateWrapper.PodTemplate != nil { + sts.Spec = merge.StatefulSetSpecs(sts.Spec, appsv1.StatefulSetSpec{Template: *appDb.PodSpec.PodTemplateWrapper.PodTemplate}) + } + return sts, nil +} + +// IsEnterprise returns whether the set image in activated with the enterprise module. +// By default, it should be true, but +// for safety mechanisms we implement a backdoor to deactivate the enterprise AC feature. +func IsEnterprise() bool { + overrideAssumption, err := strconv.ParseBool(os.Getenv(construct.MongoDBAssumeEnterpriseEnv)) + if err == nil { + return overrideAssumption + } + return true +} + +func getAppDBImage(version string) string { + repoUrl := os.Getenv(construct.MongodbRepoUrl) + imageType := envvar.GetEnvOrDefault(construct.MongoDBImageType, construct.DefaultImageType) + imageURL := os.Getenv(construct.MongodbImageEnv) + + if strings.HasSuffix(repoUrl, "/") { + repoUrl = strings.TrimRight(repoUrl, "/") + } + + assumeOldFormat := envvar.ReadBool(util.MdbAppdbAssumeOldFormat) + if strings.HasSuffix(imageURL, util.OfficialServerImageAppdbUrl) && !assumeOldFormat { + // 5.0.6-ent -> 5.0.6-ubi8 + if strings.HasSuffix(version, "-ent") { + version = strings.Replace(version, "-ent", "-"+imageType, 1) + } + // 5.0.6 -> 5.0.6-ubi8 + r := regexp.MustCompile("-.+$") + if !r.MatchString(version) { + version = version + "-" + imageType + } + // if neither, let's not change it: 5.0.6-ubi8 -> 5.0.6-ubi8 + } + + mongoImageName := ContainerImage(construct.MongodbImageEnv, version, func() string { + return imageURL + }) + + if strings.Contains(mongoImageName, "@sha256:") || strings.HasPrefix(mongoImageName, repoUrl) { + return mongoImageName + } + + return fmt.Sprintf("%s/%s", repoUrl, mongoImageName) +} + +func containerImageModification(containerName string, image string, args []string) statefulset.Modification { + return func(sts *appsv1.StatefulSet) { + for i, c := range sts.Spec.Template.Spec.Containers { + if c.Name == containerName { + c.Image = image + c.Args = args + sts.Spec.Template.Spec.Containers[i] = c + break + } + } + } +} + +// getVolumeMountIndexByName returns the volume mount with the given name from the inut slice. +// It returns -1 if this doesn't exist +func getVolumeMountIndexByName(mounts []corev1.VolumeMount, name string) int { + for i, mount := range mounts { + if mount.Name == name { + return i + } + } + return -1 +} + +// addMonitoringContainer returns a podtemplatespec modification that adds the monitoring container to the AppDB Statefulset. +// Note that this replicates some code from the functions that do this for the base AppDB Statefulset. After many iterations +// this was deemed to be an acceptable compromise to make code clearer and more maintainable. +func addMonitoringContainer(appDB om.AppDBSpec, podVars env.PodEnvVars, opts AppDBStatefulSetOptions, log *zap.SugaredLogger) podtemplatespec.Modification { + var monitoringAcVolume corev1.Volume + var monitoringACFunc podtemplatespec.Modification + + monitoringConfigMapVolume := statefulset.CreateVolumeFromConfigMap("monitoring-automation-config-goal-version", appDB.MonitoringAutomationConfigConfigMapName()) + monitoringConfigMapVolumeFunc := podtemplatespec.WithVolume(monitoringConfigMapVolume) + + if vault.IsVaultSecretBackend() { + secretsToInject := vault.AppDBSecretsToInject{Config: opts.VaultConfig} + secretsToInject.AutomationConfigSecretName = appDB.MonitoringAutomationConfigSecretName() + secretsToInject.AutomationConfigPath = util.AppDBMonitoringAutomationConfigKey + secretsToInject.AgentType = "monitoring-agent" + monitoringACFunc = podtemplatespec.WithAnnotations(secretsToInject.AppDBAnnotations(appDB.Namespace)) + } else { + // Create a volume to store the monitoring automation config. + // This is different from the AC for the automation agent, since: + // - It contains entries for "MonitoringVersions" + // - It has empty entries for ReplicaSets and Processes + monitoringAcVolume = statefulset.CreateVolumeFromSecret("monitoring-automation-config", appDB.MonitoringAutomationConfigSecretName()) + monitoringACFunc = podtemplatespec.WithVolume(monitoringAcVolume) + } + // Construct the command by concatenating: + // 1. The base command - from community + command := construct.MongodbUserCommand + construct.BaseAgentCommand() + + // 2. Add the cluster config file path + // If we are using k8s secrets, this is the same as community (and the same as the other agent container) + // But this is not possible in vault so we need two separate paths + if vault.IsVaultSecretBackend() { + command += " -cluster /var/lib/automation/config/" + util.AppDBMonitoringAutomationConfigKey + } else { + command += " -cluster /var/lib/automation/config/" + util.AppDBAutomationConfigKey + } + + // 2. Startup parameters for the agent to enable monitoring + startupParams := mdbv1.StartupParameters{ + "mmsApiKey": "${AGENT_API_KEY}", + "mmsGroupId": podVars.ProjectID, + } + + // 3. Startup parameters for the agent to enable TLS + if podVars.SSLMMSCAConfigMap != "" { + trustedCACertLocation := path.Join(caCertMountPath, util.CaCertMMS) + startupParams["sslTrustedMMSServerCertificate"] = trustedCACertLocation + } + + if podVars.SSLRequireValidMMSServerCertificates { + startupParams["sslRequireValidMMSServerCertificates"] = "" + } + + // 4. Custom startup parameters provided in the CR + // By default appDB.AutomationAgent.StartupParameters apply to both agents + // if appDB.MonitoringAgent.StartupParameters is specified, it overrides the former + monitoringStartupParams := appDB.AutomationAgent.StartupParameters + if appDB.MonitoringAgent.StartupParameters != nil { + monitoringStartupParams = appDB.MonitoringAgent.StartupParameters + } + + for k, v := range monitoringStartupParams { + startupParams[k] = v + } + + command += startupParams.ToCommandLineArgs() + monitoringCommand := []string{"/bin/bash", "-c", command} + + // Add additional TLS volumes if needed + _, monitoringMounts := getTLSVolumesAndVolumeMounts(appDB, &podVars, log) + + return podtemplatespec.Apply( + monitoringACFunc, + monitoringConfigMapVolumeFunc, + // This is a function that reads the automation agent containers, copies it and modifies it. + // We do this since the two containers are very similar with just a few differences + func(podTemplateSpec *corev1.PodTemplateSpec) { + monitoringContainer := podtemplatespec.FindContainerByName(construct.AgentName, podTemplateSpec).DeepCopy() + monitoringContainer.Name = monitoringAgentContainerName + + // we ensure that the monitoring agent image is compatible with the input of Ops Manager we're using. + if opts.MonitoringAgentVersion != "" { + monitoringContainer.Image = ContainerImage(construct.AgentImageEnv, opts.MonitoringAgentVersion, nil) + } + + // Replace the automation config volume + volumeMounts := monitoringContainer.VolumeMounts + if !vault.IsVaultSecretBackend() { + // Replace the automation config volume + acMountIndex := getVolumeMountIndexByName(volumeMounts, "automation-config") + if acMountIndex != -1 { + volumeMounts[acMountIndex].Name = monitoringAcVolume.Name + } + } + + configMapIndex := getVolumeMountIndexByName(volumeMounts, "automation-config-goal-input") + if configMapIndex != -1 { + volumeMounts[configMapIndex].Name = monitoringConfigMapVolume.Name + } + + tmpVolumeMountIndex := getVolumeMountIndexByName(volumeMounts, util.PvcNameTmp) + if tmpVolumeMountIndex != -1 { + volumeMounts[tmpVolumeMountIndex].SubPath = tmpSubpathName + } + + // Set up custom persistence options - see customPersistenceConfig() for an explanation + if appDB.HasSeparateDataAndLogsVolumes() { + journalVolumeMounts := statefulset.CreateVolumeMount(util.PvcNameJournal, util.PvcMountPathJournal) + volumeMounts = append(volumeMounts, journalVolumeMounts) + } else { + logsVolumeMount := statefulset.CreateVolumeMount(appDB.DataVolumeName(), util.PvcMountPathLogs, statefulset.WithSubPath(appDB.LogsVolumeName())) + journalVolumeMount := statefulset.CreateVolumeMount(appDB.DataVolumeName(), util.PvcMountPathJournal, statefulset.WithSubPath(util.PvcNameJournal)) + volumeMounts = append(volumeMounts, journalVolumeMount, logsVolumeMount) + } + + if !vault.IsVaultSecretBackend() { + // AGENT_API_KEY volume + volumeMounts = append(volumeMounts, statefulset.CreateVolumeMount(AgentAPIKeyVolumeName, AgentAPIKeySecretPath)) + } + container.Apply( + container.WithVolumeMounts(volumeMounts), + container.WithCommand(monitoringCommand), + container.WithResourceRequirements(buildRequirementsFromPodSpec(*NewDefaultPodSpecWrapper(*appDB.PodSpec))), + container.WithVolumeMounts(monitoringMounts), + container.WithEnvs(appdbContainerEnv(appDB, &podVars)...), + )(monitoringContainer) + podTemplateSpec.Spec.Containers = append(podTemplateSpec.Spec.Containers, *monitoringContainer) + }, + ) +} + +// appdbContainerEnv returns the set of env var needed by the AppDB. +func appdbContainerEnv(appDbSpec om.AppDBSpec, podVars *env.PodEnvVars) []corev1.EnvVar { + envVars := []corev1.EnvVar{ + { + Name: podNamespaceEnv, + ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{FieldPath: "metadata.namespace"}}, + }, + { + Name: automationConfigMapEnv, + Value: appDbSpec.Name() + "-config", + }, + { + Name: headlessAgentEnv, + Value: "true", + }, + } + return envVars +} diff --git a/controllers/operator/construct/appdb_construction_test.go b/controllers/operator/construct/appdb_construction_test.go new file mode 100644 index 000000000..05259a05e --- /dev/null +++ b/controllers/operator/construct/appdb_construction_test.go @@ -0,0 +1,110 @@ +package construct + +import ( + "fmt" + "os" + "testing" + + "github.com/mongodb/mongodb-kubernetes-operator/controllers/construct" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + omv1 "github.com/10gen/ops-manager-kubernetes/api/v1/om" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" + "github.com/stretchr/testify/assert" + "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" +) + +func init() { + logger, _ := zap.NewDevelopment() + zap.ReplaceGlobals(logger) + _ = os.Setenv(util.InitAppdbImageUrlEnv, "quay.io/mongodb/mongodb-enterprise-init-appdb") + _ = os.Setenv(util.OpsManagerMonitorAppDB, "false") +} + +func TestAppDBAgentFlags(t *testing.T) { + agentStartupParameters := mdbv1.StartupParameters{ + "Key1": "Value1", + "Key2": "Value2", + } + om := omv1.NewOpsManagerBuilderDefault().Build() + om.Spec.AppDB.AutomationAgent.StartupParameters = agentStartupParameters + sts, err := AppDbStatefulSet(om, &env.PodEnvVars{ProjectID: "abcd"}, AppDBStatefulSetOptions{}, nil) + assert.NoError(t, err) + + command := sts.Spec.Template.Spec.Containers[0].Command + assert.Contains(t, command[len(command)-1], "-Key1=Value1", "-Key2=Value2") +} + +func TestResourceRequirements(t *testing.T) { + om := omv1.NewOpsManagerBuilderDefault().Build() + + agentResourceRequirements := corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: ParseQuantityOrZero("200"), + corev1.ResourceMemory: ParseQuantityOrZero("500M"), + }, + Requests: corev1.ResourceList{ + corev1.ResourceCPU: ParseQuantityOrZero("100"), + corev1.ResourceMemory: ParseQuantityOrZero("200M"), + }, + } + + om.Spec.AppDB.PodSpec.PodTemplateWrapper = mdbv1.PodTemplateSpecWrapper{ + PodTemplate: &corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "mongodb-agent", + Resources: agentResourceRequirements, + }, + }, + }, + }, + } + + sts, err := AppDbStatefulSet(om, &env.PodEnvVars{ProjectID: "abcd"}, AppDBStatefulSetOptions{}, nil) + assert.NoError(t, err) + + for _, c := range sts.Spec.Template.Spec.Containers { + if c.Name == "mongodb-agent" { + assert.Equal(t, agentResourceRequirements, c.Resources) + } + } +} + +func TestAppDbStatefulSetWithRelatedImages(t *testing.T) { + agentRelatedImageEnv := fmt.Sprintf("RELATED_IMAGE_%s_10_26_0_6851_1", construct.AgentImageEnv) + mongodbRelatedImageEnv := fmt.Sprintf("RELATED_IMAGE_%s_1_2_3_ubi8", construct.MongodbImageEnv) + initAppdbRelatedImageEnv := fmt.Sprintf("RELATED_IMAGE_%s_3_4_5", util.InitAppdbImageUrlEnv) + + om := omv1.NewOpsManagerBuilderDefault().Build() + + t.Setenv(construct.MongodbImageEnv, "mongodb-enterprise-appdb-database-ubi") + t.Setenv(construct.MongodbRepoUrl, "quay.io/mongodb") + t.Setenv(construct.AgentImageEnv, "quay.io/mongodb/mongodb-agent:10.26.0.6851-1") + t.Setenv(util.InitAppdbImageUrlEnv, "quay.io/mongodb/mongodb-enterprise-init-appdb") + t.Setenv(initAppdbVersionEnv, "3.4.5") + + // without related imaged sts is configured using env vars + om.Spec.AppDB.Version = "1.2.3-ent" + sts, err := AppDbStatefulSet(om, &env.PodEnvVars{ProjectID: "abcd"}, AppDBStatefulSetOptions{}, nil) + assert.NoError(t, err) + assert.Equal(t, "quay.io/mongodb/mongodb-agent:10.26.0.6851-1", sts.Spec.Template.Spec.Containers[0].Image) + assert.Equal(t, "quay.io/mongodb/mongodb-enterprise-appdb-database-ubi:1.2.3-ent", sts.Spec.Template.Spec.Containers[1].Image) + assert.Equal(t, "quay.io/mongodb/mongodb-enterprise-init-appdb:3.4.5", sts.Spec.Template.Spec.InitContainers[0].Image) + + // sts should be configured with related images when they are defined + t.Setenv(agentRelatedImageEnv, "quay.io/mongodb/mongodb-agent@sha256:AGENT_SHA") + t.Setenv(mongodbRelatedImageEnv, "quay.io/mongodb/mongodb-enterprise-appdb-database-ubi@sha256:MONGODB_SHA") + t.Setenv(initAppdbRelatedImageEnv, "quay.io/mongodb/mongodb-enterprise-init-appdb@sha256:INIT_APPDB_SHA") + + om.Spec.AppDB.Version = "1.2.3-ent" + sts, err = AppDbStatefulSet(om, &env.PodEnvVars{ProjectID: "abcd"}, AppDBStatefulSetOptions{}, nil) + assert.NoError(t, err) + // agent's image is not used from RELATED_IMAGE because its value is from AGENT_IMAGE which is full image version + assert.Equal(t, "quay.io/mongodb/mongodb-agent:10.26.0.6851-1", sts.Spec.Template.Spec.Containers[0].Image) + assert.Equal(t, "quay.io/mongodb/mongodb-enterprise-appdb-database-ubi:1.2.3-ent", sts.Spec.Template.Spec.Containers[1].Image) + assert.Equal(t, "quay.io/mongodb/mongodb-enterprise-init-appdb@sha256:INIT_APPDB_SHA", sts.Spec.Template.Spec.InitContainers[0].Image) +} diff --git a/controllers/operator/construct/backup_construction.go b/controllers/operator/construct/backup_construction.go new file mode 100644 index 000000000..546e1c902 --- /dev/null +++ b/controllers/operator/construct/backup_construction.go @@ -0,0 +1,220 @@ +package construct + +import ( + "fmt" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/probes" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/secret" + "go.uber.org/zap" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + omv1 "github.com/10gen/ops-manager-kubernetes/api/v1/om" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/secrets" + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/vault" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/container" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/lifecycle" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/podtemplatespec" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/statefulset" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/util/merge" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" +) + +const ( + BackupDaemonServicePort = 8443 + backupDaemonEnv = "BACKUP_DAEMON" + healthEndpointPortEnv = "HEALTH_ENDPOINT_PORT" + backupDaemonReadinessProbeCommand = "/opt/scripts/backup-daemon-readiness-probe" + backupDaemonLivenessProbeCommand = "/opt/scripts/backup-daemon-liveness-probe.sh" + // MMSHome corresponds to MMS_HOME in the Ops Manager Dockerfile. + MMSHome = "/mongodb-ops-manager" +) + +// BackupDaemonStatefulSet fully constructs the Backup StatefulSet. +func BackupDaemonStatefulSet(secretGetUpdateCreator secrets.SecretClient, opsManager omv1.MongoDBOpsManager, log *zap.SugaredLogger, additionalOpts ...func(*OpsManagerStatefulSetOptions)) (appsv1.StatefulSet, error) { + opts := backupOptions(additionalOpts...)(opsManager) + if err := opts.updateHTTPSCertSecret(secretGetUpdateCreator, opsManager.OwnerReferences, log); err != nil { + return appsv1.StatefulSet{}, err + } + + secretName := opsManager.Spec.Backup.QueryableBackupSecretRef.Name + opts.QueryableBackupPemSecretName = secretName + if secretName != "" { + // if the secret is specified, we must have a queryable.pem entry. + _, err := secret.ReadKey(secretGetUpdateCreator, "queryable.pem", kube.ObjectKey(opsManager.Namespace, secretName)) + if err != nil { + return appsv1.StatefulSet{}, err + } + } + + backupSts := statefulset.New(backupDaemonStatefulSetFunc(opts)) + var err error + if opts.StatefulSetSpecOverride != nil { + backupSts.Spec = merge.StatefulSetSpecs(backupSts.Spec, *opts.StatefulSetSpecOverride) + } + + // the JVM env args must be determined after any potential stateful set override + // has taken place. + if err = setJvmArgsEnvVars(opsManager.Spec, util.BackupDaemonContainerName, &backupSts); err != nil { + return appsv1.StatefulSet{}, err + } + return backupSts, nil +} + +// backupOptions returns a function which returns the OpsManagerStatefulSetOptions to create the BackupDaemon StatefulSet. +func backupOptions(additionalOpts ...func(opts *OpsManagerStatefulSetOptions)) func(opsManager omv1.MongoDBOpsManager) OpsManagerStatefulSetOptions { + return func(opsManager omv1.MongoDBOpsManager) OpsManagerStatefulSetOptions { + opts := getSharedOpsManagerOptions(opsManager) + + opts.ServicePort = BackupDaemonServicePort + opts.ServiceName = opsManager.BackupServiceName() + opts.Name = opsManager.BackupStatefulSetName() + opts.Replicas = opsManager.Spec.Backup.Members + opts.AppDBConnectionSecretName = opsManager.AppDBMongoConnectionStringSecretName() + opts.OpsManagerCaName = opsManager.Spec.GetOpsManagerCA() + + if opsManager.Spec.Backup != nil { + if opsManager.Spec.Backup.StatefulSetConfiguration != nil { + opts.StatefulSetSpecOverride = &opsManager.Spec.Backup.StatefulSetConfiguration.SpecWrapper.Spec + } + if opsManager.Spec.Backup.HeadDB != nil { + opts.HeadDbPersistenceConfig = opsManager.Spec.Backup.HeadDB + } + } + + for _, additionalOpt := range additionalOpts { + additionalOpt(&opts) + } + + return opts + } +} + +// backupDaemonStatefulSetFunc constructs the Backup Daemon podTemplateSpec modification function. +func backupDaemonStatefulSetFunc(opts OpsManagerStatefulSetOptions) statefulset.Modification { + // PodSecurityContext is added in the backupAndOpsManagerSharedConfiguration + _, configureContainerSecurityContext := podtemplatespec.WithDefaultSecurityContextsModifications() + + defaultConfig := mdbv1.PersistenceConfig{Storage: util.DefaultHeadDbStorageSize} + pvc := pvcFunc(util.PvcNameHeadDb, opts.HeadDbPersistenceConfig, defaultConfig, opts.Labels) + headDbMount := statefulset.CreateVolumeMount(util.PvcNameHeadDb, util.PvcMountPathHeadDb) + + postStart := func(lc *corev1.Lifecycle) {} + + caVolumeFunc := podtemplatespec.NOOP() + caVolumeMountFunc := container.NOOP() + if opts.AppDBTlsCAConfigMapName != "" { + // It will add each X.509 public key certificate into JVM's trust store + // with unique "mongodb_operator_added_trust_ca_$RANDOM" alias + // See: https://jira.mongodb.org/browse/HELP-25872 for more details. + postStart = func(lc *corev1.Lifecycle) { + if lc.PostStart == nil { + lc.PostStart = &corev1.LifecycleHandler{Exec: &corev1.ExecAction{}} + } + lc.PostStart.Exec.Command = []string{"/bin/sh", "-c", postStartScriptCmd()} + } + } + + volumeMounts := []corev1.VolumeMount{headDbMount} + mmsMongoUriVolume := corev1.Volume{} + var mmsMongoUriMount corev1.VolumeMount + + if !vault.IsVaultSecretBackend() { + // configure the AppDB Connection String volume from a secret + mmsMongoUriVolume, mmsMongoUriMount = buildMmsMongoUriVolume(opts) + volumeMounts = append(volumeMounts, mmsMongoUriMount) + } + + return statefulset.Apply( + backupAndOpsManagerSharedConfiguration(opts), + statefulset.WithVolumeClaim(util.PvcNameHeadDb, pvc), + statefulset.WithPodSpecTemplate( + podtemplatespec.Apply( + // 70 minutes for Backup Damon (internal timeout is 65 minutes, see CLOUDP-61849) + podtemplatespec.WithTerminationGracePeriodSeconds(4200), + addUriVolume(mmsMongoUriVolume), + caVolumeFunc, + podtemplatespec.WithContainerByIndex(0, + container.Apply( + container.WithName(util.BackupDaemonContainerName), + container.WithEnvs(backupDaemonEnvVars()...), + container.WithLifecycle(buildBackupDaemonLifecycle()), + container.WithLifecycle(postStart), + container.WithVolumeMounts(volumeMounts), + container.WithLivenessProbe(buildBackupDaemonLivenessProbe()), + container.WithReadinessProbe(buildBackupDaemonReadinessProbe()), + container.WithStartupProbe(buildBackupDaemonStartupProbe()), + caVolumeMountFunc, + configureContainerSecurityContext, + ), + )), + ), + ) +} + +func addUriVolume(volume corev1.Volume) podtemplatespec.Modification { + if !vault.IsVaultSecretBackend() { + return podtemplatespec.WithVolume(volume) + } + return podtemplatespec.NOOP() +} + +func backupDaemonEnvVars() []corev1.EnvVar { + return []corev1.EnvVar{ + { + // For the OM Docker image to run as Backup Daemon, the BACKUP_DAEMON env variable + // needs to be passed with any value.configureJvmParams + Name: backupDaemonEnv, + Value: "backup", + }, + { + // Specify the port of the backup daemon health endpoint for the liveness probe. + Name: healthEndpointPortEnv, + Value: fmt.Sprintf("%d", backupDaemonHealthPort), + }} +} + +func buildBackupDaemonLifecycle() lifecycle.Modification { + return lifecycle.WithPrestopCommand([]string{"/bin/sh", "-c", "/mongodb-ops-manager/bin/mongodb-mms stop_backup_daemon"}) +} + +// buildBackupDaemonReadinessProbe returns a probe modification which will add +// the readiness probe. +func buildBackupDaemonReadinessProbe() probes.Modification { + return probes.Apply( + probes.WithExecCommand([]string{backupDaemonReadinessProbeCommand}), + probes.WithFailureThreshold(3), + probes.WithInitialDelaySeconds(1), + probes.WithSuccessThreshold(1), + probes.WithPeriodSeconds(3), + probes.WithTimeoutSeconds(5), + ) +} + +// buildBackupDaemonLivenessProbe returns a probe modification which will add +// the liveness probe. +func buildBackupDaemonLivenessProbe() probes.Modification { + return probes.Apply( + probes.WithExecCommand([]string{backupDaemonLivenessProbeCommand}), + probes.WithFailureThreshold(10), + probes.WithInitialDelaySeconds(10), + probes.WithSuccessThreshold(1), + probes.WithPeriodSeconds(30), + probes.WithTimeoutSeconds(5), + ) +} + +// buildBackupDaemonStartupProbe returns a probe modification which will add +// the startup probe. +func buildBackupDaemonStartupProbe() probes.Modification { + return probes.Apply( + probes.WithExecCommand([]string{backupDaemonLivenessProbeCommand}), + probes.WithFailureThreshold(20), + probes.WithInitialDelaySeconds(1), + probes.WithSuccessThreshold(1), + probes.WithPeriodSeconds(30), + probes.WithTimeoutSeconds(5), + ) +} diff --git a/controllers/operator/construct/backup_construction_test.go b/controllers/operator/construct/backup_construction_test.go new file mode 100644 index 000000000..16d2ef18e --- /dev/null +++ b/controllers/operator/construct/backup_construction_test.go @@ -0,0 +1,101 @@ +package construct + +import ( + "fmt" + "testing" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/probes" + "go.uber.org/zap" + + omv1 "github.com/10gen/ops-manager-kubernetes/api/v1/om" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/mock" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/secrets" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/vault" + "github.com/stretchr/testify/assert" +) + +func TestBuildBackupDaemonStatefulSet(t *testing.T) { + client := mock.NewClient() + secretsClient := secrets.SecretClient{ + VaultClient: &vault.VaultClient{}, + KubeClient: client, + } + sts, err := BackupDaemonStatefulSet(secretsClient, omv1.NewOpsManagerBuilderDefault().SetName("test-om").Build(), zap.S()) + assert.NoError(t, err) + assert.Equal(t, "test-om-backup-daemon", sts.ObjectMeta.Name) + assert.Equal(t, util.BackupDaemonContainerName, sts.Spec.Template.Spec.Containers[0].Name) + assert.NotNil(t, sts.Spec.Template.Spec.Containers[0].ReadinessProbe) +} + +func TestBackupPodTemplate_TerminationTimeout(t *testing.T) { + client := mock.NewClient() + secretsClient := secrets.SecretClient{ + VaultClient: &vault.VaultClient{}, + KubeClient: client, + } + set, err := BackupDaemonStatefulSet(secretsClient, omv1.NewOpsManagerBuilderDefault().SetName("test-om").Build(), zap.S()) + assert.NoError(t, err) + podSpecTemplate := set.Spec.Template + assert.Equal(t, int64(4200), *podSpecTemplate.Spec.TerminationGracePeriodSeconds) +} + +func TestBuildBackupDaemonContainer(t *testing.T) { + client := mock.NewClient() + secretsClient := secrets.SecretClient{ + VaultClient: &vault.VaultClient{}, + KubeClient: client, + } + sts, err := BackupDaemonStatefulSet(secretsClient, omv1.NewOpsManagerBuilderDefault().SetVersion("4.2.0").Build(), zap.S()) + assert.NoError(t, err) + template := sts.Spec.Template + container := template.Spec.Containers[0] + assert.Equal(t, "quay.io/mongodb/mongodb-enterprise-ops-manager:4.2.0", container.Image) + + assert.Equal(t, util.BackupDaemonContainerName, container.Name) + + expectedProbe := probes.New(buildBackupDaemonReadinessProbe()) + assert.Equal(t, &expectedProbe, container.ReadinessProbe) + + expectedProbe = probes.New(buildBackupDaemonLivenessProbe()) + assert.Equal(t, &expectedProbe, container.LivenessProbe) + + expectedProbe = probes.New(buildBackupDaemonStartupProbe()) + assert.Equal(t, &expectedProbe, container.StartupProbe) + + assert.Equal(t, []string{"/bin/sh", "-c", "/mongodb-ops-manager/bin/mongodb-mms stop_backup_daemon"}, + container.Lifecycle.PreStop.Exec.Command) +} + +func TestMultipleBackupDaemons(t *testing.T) { + client := mock.NewClient() + secretsClient := secrets.SecretClient{ + VaultClient: &vault.VaultClient{}, + KubeClient: client, + } + sts, err := BackupDaemonStatefulSet(secretsClient, omv1.NewOpsManagerBuilderDefault().SetVersion("4.2.0").SetBackupMembers(3).Build(), zap.S()) + assert.NoError(t, err) + assert.Equal(t, 3, int(*sts.Spec.Replicas)) +} + +func Test_BackupDaemonStatefulSetWithRelatedImages(t *testing.T) { + initOpsManagerRelatedImageEnv := fmt.Sprintf("RELATED_IMAGE_%s_1_2_3", util.InitOpsManagerImageUrl) + opsManagerRelatedImageEnv := fmt.Sprintf("RELATED_IMAGE_%s_5_0_0", util.OpsManagerImageUrl) + + t.Setenv(util.InitOpsManagerImageUrl, "quay.io/mongodb/mongodb-enterprise-init-appdb") + t.Setenv(util.InitOpsManagerVersion, "1.2.3") + t.Setenv(util.OpsManagerImageUrl, "quay.io/mongodb/mongodb-enterprise-ops-manager") + t.Setenv(initOpsManagerRelatedImageEnv, "quay.io/mongodb/mongodb-enterprise-init-ops-manager:@sha256:MONGODB_INIT_APPDB") + t.Setenv(opsManagerRelatedImageEnv, "quay.io/mongodb/mongodb-enterprise-ops-manager:@sha256:MONGODB_OPS_MANAGER") + + secretsClient := secrets.SecretClient{ + VaultClient: &vault.VaultClient{}, + KubeClient: mock.NewClient(), + } + + sts, err := BackupDaemonStatefulSet(secretsClient, omv1.NewOpsManagerBuilderDefault().SetVersion("5.0.0").Build(), zap.S()) + assert.NoError(t, err) + assert.Equal(t, "quay.io/mongodb/mongodb-enterprise-init-ops-manager:@sha256:MONGODB_INIT_APPDB", sts.Spec.Template.Spec.InitContainers[0].Image) + assert.Equal(t, "quay.io/mongodb/mongodb-enterprise-ops-manager:@sha256:MONGODB_OPS_MANAGER", sts.Spec.Template.Spec.Containers[0].Image) +} diff --git a/controllers/operator/construct/construction_test.go b/controllers/operator/construct/construction_test.go new file mode 100644 index 000000000..f0882c8fd --- /dev/null +++ b/controllers/operator/construct/construction_test.go @@ -0,0 +1,433 @@ +package construct + +import ( + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + + omv1 "github.com/10gen/ops-manager-kubernetes/api/v1/om" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/mock" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/service" + "github.com/stretchr/testify/assert" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestBuildStatefulSet_PersistentFlag(t *testing.T) { + mdb := mdbv1.NewReplicaSetBuilder().SetPersistent(nil).Build() + set := DatabaseStatefulSet(*mdb, ReplicaSetOptions(GetPodEnvOptions()), nil) + assert.Len(t, set.Spec.VolumeClaimTemplates, 1) + assert.Len(t, set.Spec.Template.Spec.Containers[0].VolumeMounts, 8) + + mdb = mdbv1.NewReplicaSetBuilder().SetPersistent(util.BooleanRef(true)).Build() + set = DatabaseStatefulSet(*mdb, ReplicaSetOptions(GetPodEnvOptions()), nil) + assert.Len(t, set.Spec.VolumeClaimTemplates, 1) + assert.Len(t, set.Spec.Template.Spec.Containers[0].VolumeMounts, 8) + + // If no persistence is set then we still mount init scripts + mdb = mdbv1.NewReplicaSetBuilder().SetPersistent(util.BooleanRef(false)).Build() + set = DatabaseStatefulSet(*mdb, ReplicaSetOptions(GetPodEnvOptions()), nil) + assert.Len(t, set.Spec.VolumeClaimTemplates, 0) + assert.Len(t, set.Spec.Template.Spec.Containers[0].VolumeMounts, 8) +} + +// TestBuildStatefulSet_PersistentVolumeClaimSingle checks that one persistent volume claim is created that is mounted by +// 3 points +func TestBuildStatefulSet_PersistentVolumeClaimSingle(t *testing.T) { + labels := map[string]string{"app": "foo"} + persistence := mdbv1.NewPersistenceBuilder("40G").SetStorageClass("fast").SetLabelSelector(labels) + podSpec := mdbv1.NewPodSpecWrapperBuilder().SetSinglePersistence(persistence).Build().MongoDbPodSpec + rs := mdbv1.NewReplicaSetBuilder().SetPersistent(nil).SetPodSpec(&podSpec).Build() + set := DatabaseStatefulSet(*rs, ReplicaSetOptions(GetPodEnvOptions()), nil) + + checkPvClaims(t, set, []corev1.PersistentVolumeClaim{pvClaim(util.PvcNameData, "40G", stringutil.Ref("fast"), labels)}) + + checkMounts(t, set, []corev1.VolumeMount{ + {Name: util.PvMms, MountPath: util.PvcMmsHomeMountPath, SubPath: util.PvcMmsHome}, + {Name: util.PvMms, MountPath: util.PvcMountPathTmp, SubPath: util.PvcNameTmp}, + {Name: util.PvMms, MountPath: util.PvcMmsMountPath, SubPath: util.PvcMms}, + {Name: AgentAPIKeyVolumeName, MountPath: AgentAPIKeySecretPath}, + {Name: util.PvcNameData, MountPath: util.PvcMountPathData, SubPath: util.PvcNameData}, + {Name: util.PvcNameData, MountPath: util.PvcMountPathJournal, SubPath: util.PvcNameJournal}, + {Name: util.PvcNameData, MountPath: util.PvcMountPathLogs, SubPath: util.PvcNameLogs}, + {Name: PvcNameDatabaseScripts, MountPath: PvcMountPathScripts, ReadOnly: true}, + }) +} + +// TestBuildStatefulSet_PersistentVolumeClaimMultiple checks multiple volumes for multiple mounts. Note, that subpaths +// for mount points are not created (unlike in single mode) +func TestBuildStatefulSet_PersistentVolumeClaimMultiple(t *testing.T) { + labels1 := map[string]string{"app": "bar"} + labels2 := map[string]string{"app": "foo"} + podSpec := mdbv1.NewPodSpecWrapperBuilder().SetMultiplePersistence( + mdbv1.NewPersistenceBuilder("40G").SetStorageClass("fast"), + mdbv1.NewPersistenceBuilder("3G").SetStorageClass("slow").SetLabelSelector(labels1), + mdbv1.NewPersistenceBuilder("500M").SetStorageClass("fast").SetLabelSelector(labels2), + ).Build() + + mdb := mdbv1.NewReplicaSetBuilder().SetPersistent(nil).SetPodSpec(&podSpec.MongoDbPodSpec).Build() + set := DatabaseStatefulSet(*mdb, ReplicaSetOptions(GetPodEnvOptions()), nil) + + checkPvClaims(t, set, []corev1.PersistentVolumeClaim{ + pvClaim(util.PvcNameData, "40G", stringutil.Ref("fast"), nil), + pvClaim(util.PvcNameJournal, "3G", stringutil.Ref("slow"), labels1), + pvClaim(util.PvcNameLogs, "500M", stringutil.Ref("fast"), labels2), + }) + + checkMounts(t, set, []corev1.VolumeMount{ + {Name: util.PvMms, MountPath: util.PvcMmsHomeMountPath, SubPath: util.PvcMmsHome}, + {Name: util.PvMms, MountPath: util.PvcMountPathTmp, SubPath: util.PvcNameTmp}, + {Name: util.PvMms, MountPath: util.PvcMmsMountPath, SubPath: util.PvcMms}, + {Name: AgentAPIKeyVolumeName, MountPath: AgentAPIKeySecretPath}, + {Name: util.PvcNameData, MountPath: util.PvcMountPathData}, + {Name: PvcNameDatabaseScripts, MountPath: PvcMountPathScripts, ReadOnly: true}, + {Name: util.PvcNameJournal, MountPath: util.PvcMountPathJournal}, + {Name: util.PvcNameLogs, MountPath: util.PvcMountPathLogs}, + }) +} + +// TestBuildStatefulSet_PersistentVolumeClaimMultipleDefaults checks the scenario when storage is provided only for one +// mount point. Default values are expected to be used for two others +func TestBuildStatefulSet_PersistentVolumeClaimMultipleDefaults(t *testing.T) { + podSpec := mdbv1.NewPodSpecWrapperBuilder().SetMultiplePersistence( + mdbv1.NewPersistenceBuilder("40G").SetStorageClass("fast"), + nil, + nil). + Build() + mdb := mdbv1.NewReplicaSetBuilder().SetPersistent(nil).SetPodSpec(&podSpec.MongoDbPodSpec).Build() + set := DatabaseStatefulSet(*mdb, ReplicaSetOptions(GetPodEnvOptions()), nil) + + checkPvClaims(t, set, []corev1.PersistentVolumeClaim{ + pvClaim(util.PvcNameData, "40G", stringutil.Ref("fast"), nil), + pvClaim(util.PvcNameJournal, util.DefaultJournalStorageSize, nil, nil), + pvClaim(util.PvcNameLogs, util.DefaultLogsStorageSize, nil, nil), + }) + + checkMounts(t, set, []corev1.VolumeMount{ + {Name: util.PvMms, MountPath: util.PvcMmsHomeMountPath, SubPath: util.PvcMmsHome}, + {Name: util.PvMms, MountPath: util.PvcMountPathTmp, SubPath: util.PvcNameTmp}, + {Name: util.PvMms, MountPath: util.PvcMmsMountPath, SubPath: util.PvcMms}, + {Name: AgentAPIKeyVolumeName, MountPath: AgentAPIKeySecretPath}, + {Name: util.PvcNameData, MountPath: util.PvcMountPathData}, + {Name: PvcNameDatabaseScripts, MountPath: PvcMountPathScripts, ReadOnly: true}, + {Name: util.PvcNameJournal, MountPath: util.PvcMountPathJournal}, + {Name: util.PvcNameLogs, MountPath: util.PvcMountPathLogs}, + }) +} + +func TestBuildAppDbStatefulSetDefault(t *testing.T) { + appDbSts, err := AppDbStatefulSet(omv1.NewOpsManagerBuilderDefault().Build(), &env.PodEnvVars{ProjectID: "abcd"}, AppDBStatefulSetOptions{}, nil) + assert.NoError(t, err) + podSpecTemplate := appDbSts.Spec.Template.Spec + assert.Len(t, podSpecTemplate.InitContainers, 1) + assert.Len(t, podSpecTemplate.Containers, 2, "Should contain mongodb and agent") + assert.Equal(t, "mongodb-agent", podSpecTemplate.Containers[0].Name) + assert.Equal(t, "mongod", podSpecTemplate.Containers[1].Name) +} + +func TestBasePodSpec_Affinity(t *testing.T) { + nodeAffinity := defaultNodeAffinity() + podAffinity := defaultPodAffinity() + + podSpec := mdbv1.NewPodSpecWrapperBuilder(). + SetNodeAffinity(nodeAffinity). + SetPodAffinity(podAffinity). + SetPodAntiAffinityTopologyKey("nodeId"). + Build() + mdb := mdbv1.NewReplicaSetBuilder(). + SetName("s"). + SetPodSpec(&podSpec.MongoDbPodSpec). + Build() + sts := DatabaseStatefulSet(*mdb, ReplicaSetOptions(GetPodEnvOptions()), nil) + + spec := sts.Spec.Template.Spec + assert.Equal(t, nodeAffinity, *spec.Affinity.NodeAffinity) + assert.Equal(t, podAffinity, *spec.Affinity.PodAffinity) + assert.Len(t, spec.Affinity.PodAntiAffinity.PreferredDuringSchedulingIgnoredDuringExecution, 1) + assert.Len(t, spec.Affinity.PodAntiAffinity.RequiredDuringSchedulingIgnoredDuringExecution, 0) + term := spec.Affinity.PodAntiAffinity.PreferredDuringSchedulingIgnoredDuringExecution[0] + assert.Equal(t, int32(100), term.Weight) + assert.Equal(t, map[string]string{PodAntiAffinityLabelKey: "s"}, term.PodAffinityTerm.LabelSelector.MatchLabels) + assert.Equal(t, "nodeId", term.PodAffinityTerm.TopologyKey) +} + +// TestBasePodSpec_AntiAffinityDefaultTopology checks that the default topology key is created if the topology key is +// not specified +func TestBasePodSpec_AntiAffinityDefaultTopology(t *testing.T) { + sts := DatabaseStatefulSet(*mdbv1.NewStandaloneBuilder().SetName("my-standalone").Build(), StandaloneOptions(GetPodEnvOptions()), nil) + + spec := sts.Spec.Template.Spec + term := spec.Affinity.PodAntiAffinity.PreferredDuringSchedulingIgnoredDuringExecution[0] + assert.Equal(t, int32(100), term.Weight) + assert.Equal(t, map[string]string{PodAntiAffinityLabelKey: "my-standalone"}, term.PodAffinityTerm.LabelSelector.MatchLabels) + assert.Equal(t, util.DefaultAntiAffinityTopologyKey, term.PodAffinityTerm.TopologyKey) +} + +// TestBasePodSpec_ImagePullSecrets verifies that 'spec.ImagePullSecrets' is created only if env variable +// IMAGE_PULL_SECRETS is initialized +func TestBasePodSpec_ImagePullSecrets(t *testing.T) { + // Cleaning the state (there is no tear down in go test :( ) + defer mock.InitDefaultEnvVariables() + + sts := DatabaseStatefulSet(*mdbv1.NewStandaloneBuilder().Build(), StandaloneOptions(GetPodEnvOptions()), nil) + + template := sts.Spec.Template + assert.Nil(t, template.Spec.ImagePullSecrets) + + t.Setenv(util.ImagePullSecrets, "foo") + + sts = DatabaseStatefulSet(*mdbv1.NewStandaloneBuilder().Build(), StandaloneOptions(GetPodEnvOptions()), nil) + + template = sts.Spec.Template + assert.Equal(t, []corev1.LocalObjectReference{{Name: "foo"}}, template.Spec.ImagePullSecrets) + +} + +// TestBasePodSpec_TerminationGracePeriodSeconds verifies that the TerminationGracePeriodSeconds is set to 600 seconds +func TestBasePodSpec_TerminationGracePeriodSeconds(t *testing.T) { + sts := DatabaseStatefulSet(*mdbv1.NewReplicaSetBuilder().Build(), ReplicaSetOptions(GetPodEnvOptions()), nil) + assert.Equal(t, util.Int64Ref(600), sts.Spec.Template.Spec.TerminationGracePeriodSeconds) +} + +func checkPvClaims(t *testing.T, set appsv1.StatefulSet, expectedClaims []corev1.PersistentVolumeClaim) { + assert.Len(t, set.Spec.VolumeClaimTemplates, len(expectedClaims)) + + for i, c := range expectedClaims { + assert.Equal(t, c, set.Spec.VolumeClaimTemplates[i]) + } +} +func checkMounts(t *testing.T, set appsv1.StatefulSet, expectedMounts []corev1.VolumeMount) { + assert.Len(t, set.Spec.Template.Spec.Containers[0].VolumeMounts, len(expectedMounts)) + + for i, c := range expectedMounts { + assert.Equal(t, c, set.Spec.Template.Spec.Containers[0].VolumeMounts[i]) + } +} + +func pvClaim(pvName, size string, storageClass *string, labels map[string]string) corev1.PersistentVolumeClaim { + quantity, _ := resource.ParseQuantity(size) + expectedClaim := corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: pvName, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceStorage: quantity}, + }, + StorageClassName: storageClass, + }} + if len(labels) > 0 { + expectedClaim.Spec.Selector = &metav1.LabelSelector{MatchLabels: labels} + } + return expectedClaim +} + +func TestDefaultPodSpec_FsGroup(t *testing.T) { + defer mock.InitDefaultEnvVariables() + + sts := DatabaseStatefulSet(*mdbv1.NewStandaloneBuilder().Build(), StandaloneOptions(GetPodEnvOptions()), nil) + + spec := sts.Spec.Template.Spec + assert.Len(t, spec.InitContainers, 1) + assert.NotNil(t, spec.SecurityContext) + assert.Equal(t, util.Int64Ref(util.FsGroup), spec.SecurityContext.FSGroup) + + t.Setenv(util.ManagedSecurityContextEnv, "true") + + sts = DatabaseStatefulSet(*mdbv1.NewStandaloneBuilder().Build(), StandaloneOptions(GetPodEnvOptions()), nil) + assert.Nil(t, sts.Spec.Template.Spec.SecurityContext) +} + +func TestPodSpec_Requirements(t *testing.T) { + podSpec := mdbv1.NewPodSpecWrapperBuilder(). + SetCpuRequests("0.1"). + SetMemoryRequest("512M"). + SetCpuLimit("0.3"). + SetMemoryLimit("1012M"). + Build() + + sts := DatabaseStatefulSet(*mdbv1.NewReplicaSetBuilder().SetPodSpec(&podSpec.MongoDbPodSpec).Build(), ReplicaSetOptions(GetPodEnvOptions()), nil) + + podSpecTemplate := sts.Spec.Template + container := podSpecTemplate.Spec.Containers[0] + + expectedLimits := corev1.ResourceList{corev1.ResourceCPU: ParseQuantityOrZero("0.3"), corev1.ResourceMemory: ParseQuantityOrZero("1012M")} + expectedRequests := corev1.ResourceList{corev1.ResourceCPU: ParseQuantityOrZero("0.1"), corev1.ResourceMemory: ParseQuantityOrZero("512M")} + assert.Equal(t, expectedLimits, container.Resources.Limits) + assert.Equal(t, expectedRequests, container.Resources.Requests) +} + +func TestService_merge0(t *testing.T) { + dst := corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "my-service", Namespace: "my-namespace"}} + src := corev1.Service{} + dst = service.Merge(dst, src) + assert.Equal(t, "my-service", dst.ObjectMeta.Name) + assert.Equal(t, "my-namespace", dst.ObjectMeta.Namespace) + + // Name and Namespace will not be copied over. + src = corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "new-service", Namespace: "new-namespace"}} + dst = service.Merge(dst, src) + assert.Equal(t, "my-service", dst.ObjectMeta.Name) + assert.Equal(t, "my-namespace", dst.ObjectMeta.Namespace) +} + +func TestService_NodePortIsNotOverwritten(t *testing.T) { + dst := corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "my-service", Namespace: "my-namespace"}, + Spec: corev1.ServiceSpec{Ports: []corev1.ServicePort{{NodePort: 30030}}}, + } + src := corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "my-service", Namespace: "my-namespace"}, + Spec: corev1.ServiceSpec{}, + } + + dst = service.Merge(dst, src) + assert.Equal(t, int32(30030), dst.Spec.Ports[0].NodePort) +} + +func TestCreateOrUpdateService_NodePortsArePreservedWhenThereIsMoreThanOnePortDefined(t *testing.T) { + port1 := corev1.ServicePort{ + Name: "port1", + Port: 1000, + TargetPort: intstr.IntOrString{IntVal: 1001}, + NodePort: 30030, + } + + port2 := corev1.ServicePort{ + Name: "port2", + Port: 2000, + TargetPort: intstr.IntOrString{IntVal: 2001}, + NodePort: 40040, + } + + manager := mock.NewEmptyManager() + manager.Client.AddDefaultMdbConfigResources() + + existingService := corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "my-service", Namespace: "my-namespace"}, + Spec: corev1.ServiceSpec{Ports: []corev1.ServicePort{port1, port2}}} + + err := service.CreateOrUpdateService(manager.Client, existingService) + assert.NoError(t, err) + + port1WithNodePortZero := port1 + port1WithNodePortZero.NodePort = 0 + port2WithNodePortZero := port2 + port2WithNodePortZero.NodePort = 0 + + newServiceWithoutNodePorts := corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "my-service", Namespace: "my-namespace"}, + Spec: corev1.ServiceSpec{Ports: []corev1.ServicePort{port1WithNodePortZero, port2WithNodePortZero}}} + + err = service.CreateOrUpdateService(manager.Client, newServiceWithoutNodePorts) + assert.NoError(t, err) + + changedService, err := manager.Client.GetService(types.NamespacedName{Name: "my-service", Namespace: "my-namespace"}) + require.NoError(t, err) + require.NotNil(t, changedService) + require.Len(t, changedService.Spec.Ports, 2) + + assert.Equal(t, port1.NodePort, changedService.Spec.Ports[0].NodePort) + assert.Equal(t, port2.NodePort, changedService.Spec.Ports[1].NodePort) +} + +func TestService_NodePortIsNotOverwrittenIfNoNodePortIsSpecified(t *testing.T) { + dst := corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "my-service", Namespace: "my-namespace"}, + Spec: corev1.ServiceSpec{Ports: []corev1.ServicePort{{NodePort: 30030}}}, + } + src := corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "my-service", Namespace: "my-namespace"}, + Spec: corev1.ServiceSpec{Ports: []corev1.ServicePort{{}}}, + } + + dst = service.Merge(dst, src) + assert.Equal(t, int32(30030), dst.Spec.Ports[0].NodePort) +} + +func TestService_NodePortIsKeptWhenChangingServiceType(t *testing.T) { + dst := corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "my-service", Namespace: "my-namespace"}, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{{NodePort: 30030}}, + Type: corev1.ServiceTypeLoadBalancer, + }, + } + src := corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "my-service", Namespace: "my-namespace"}, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{{NodePort: 30099}}, + Type: corev1.ServiceTypeNodePort, + }, + } + dst = service.Merge(dst, src) + assert.Equal(t, int32(30099), dst.Spec.Ports[0].NodePort) + + src = corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "my-service", Namespace: "my-namespace"}, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{{NodePort: 30011}}, + Type: corev1.ServiceTypeLoadBalancer, + }, + } + + dst = service.Merge(dst, src) + assert.Equal(t, int32(30011), dst.Spec.Ports[0].NodePort) +} + +func TestService_mergeAnnotations(t *testing.T) { + // Annotations will be added + annotationsDest := make(map[string]string) + annotationsDest["annotation0"] = "value0" + annotationsDest["annotation1"] = "value1" + annotationsSrc := make(map[string]string) + annotationsSrc["annotation0"] = "valueXXXX" + annotationsSrc["annotation2"] = "value2" + + src := corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: annotationsSrc, + }, + } + dst := corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-service", Namespace: "my-namespace", + Annotations: annotationsDest, + }, + } + dst = service.Merge(dst, src) + assert.Len(t, dst.ObjectMeta.Annotations, 3) + assert.Equal(t, dst.ObjectMeta.Annotations["annotation0"], "valueXXXX") +} + +func defaultPodVars() *env.PodEnvVars { + return &env.PodEnvVars{BaseURL: "http://localhost:8080", ProjectID: "myProject", User: "user@some.com"} +} + +func TestPodAntiAffinityOverride(t *testing.T) { + podAntiAffinity := defaultPodAntiAffinity() + + podSpec := mdbv1.NewPodSpecWrapperBuilder().Build() + + // override pod Anti Affinity + podSpec.PodTemplateWrapper.PodTemplate.Spec.Affinity.PodAntiAffinity = &podAntiAffinity + + mdb := mdbv1.NewReplicaSetBuilder(). + SetName("s"). + SetPodSpec(&podSpec.MongoDbPodSpec). + Build() + sts := DatabaseStatefulSet(*mdb, ReplicaSetOptions(GetPodEnvOptions()), nil) + spec := sts.Spec.Template.Spec + assert.Equal(t, podAntiAffinity, *spec.Affinity.PodAntiAffinity) +} diff --git a/controllers/operator/construct/database_construction.go b/controllers/operator/construct/database_construction.go new file mode 100644 index 000000000..048bed5c8 --- /dev/null +++ b/controllers/operator/construct/database_construction.go @@ -0,0 +1,949 @@ +package construct + +import ( + "fmt" + "os" + "sort" + "strconv" + "strings" + + "go.uber.org/zap" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/agents" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/certs" + "github.com/10gen/ops-manager-kubernetes/pkg/vault" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/util/merge" + + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + mdbcv1 "github.com/mongodb/mongodb-kubernetes-operator/api/v1" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/util/scale" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/container" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/persistentvolumeclaim" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/podtemplatespec" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/probes" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/statefulset" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" +) + +const ( + // Volume constants + PvcNameDatabaseScripts = "database-scripts" + PvcMountPathScripts = "/opt/scripts" + + caCertMountPath = "/mongodb-automation/certs" + // CaCertName is the name of the volume with the CA Cert + CaCertName = "ca-cert-volume" + // AgentCertMountPath defines where in the Pod the ca cert will be mounted. + agentCertMountPath = "/mongodb-automation/" + util.AgentSecretName + + databaseLivenessProbeCommand = "/opt/scripts/probe.sh" + databaseReadinessProbeCommand = "/opt/scripts/readinessprobe" + + ControllerLabelName = "controller" + InitDatabaseContainerName = "mongodb-enterprise-init-database" + + // Database environment variable names + InitDatabaseVersionEnv = "INIT_DATABASE_VERSION" + DatabaseVersionEnv = "DATABASE_VERSION" + + // PodAntiAffinityLabelKey defines the anti affinity rule label. The main rule is to spread entities inside one statefulset + // (aka replicaset) to different locations, so pods having the same label shouldn't coexist on the node that has + // the same topology key + PodAntiAffinityLabelKey = "pod-anti-affinity" + + // AGENT_API_KEY secret path + AgentAPIKeySecretPath = "/mongodb-automation/agent-api-key" + AgentAPIKeyVolumeName = "agent-api-key" +) + +type StsType int + +const ( + Undefined StsType = iota + ReplicaSet + Mongos + Config + Shard + Standalone + MultiReplicaSet +) + +// DatabaseStatefulSetOptions contains all the different values that are variable between +// StatefulSets. Depending on which StatefulSet is being built, a number of these will be pre-set, +// while the remainder will be configurable via configuration functions which modify this type. +type DatabaseStatefulSetOptions struct { + Replicas int + Name string + ServiceName string + PodSpec *mdbv1.PodSpecWrapper + PodVars *env.PodEnvVars + CurrentAgentAuthMode string + CertificateHash string + PrometheusTLSCertHash string + InternalClusterHash string + ServicePort int32 + Persistent *bool + OwnerReference []metav1.OwnerReference + AgentConfig mdbv1.AgentConfig + StatefulSetSpecOverride *appsv1.StatefulSetSpec + StsType StsType + + Annotations map[string]string + VaultConfig vault.VaultConfiguration + ExtraEnvs []corev1.EnvVar + Labels map[string]string + + // These fields are only relevant for multi-cluster + MultiClusterMode string // should always be "false" in single-cluster + // This needs to be provided for the multi-cluster statefulsets as they contain a member index in the name. + // The name override is used for naming the statefulset and the pod affinity label. + // The certificate secrets and other dependencies named using the resource name will use the `Name` field. + StatefulSetNameOverride string // this needs to be overriden of the + HostNameOverrideConfigmapName string +} + +func (d DatabaseStatefulSetOptions) IsMongos() bool { + return d.StsType == Mongos +} + +// databaseStatefulSetSource is an interface which provides all the required fields to fully construct +// a database StatefulSet. +type databaseStatefulSetSource interface { + GetName() string + GetNamespace() string + + GetSecurity() *mdbv1.Security + + GetPrometheus() *mdbcv1.Prometheus +} + +// StandaloneOptions returns a set of options which will configure a Standalone StatefulSet +func StandaloneOptions(additionalOpts ...func(options *DatabaseStatefulSetOptions)) func(mdb mdbv1.MongoDB) DatabaseStatefulSetOptions { + return func(mdb mdbv1.MongoDB) DatabaseStatefulSetOptions { + var stsSpec *appsv1.StatefulSetSpec = nil + if mdb.Spec.PodSpec.PodTemplateWrapper.PodTemplate != nil { + stsSpec = &appsv1.StatefulSetSpec{Template: *mdb.Spec.PodSpec.PodTemplateWrapper.PodTemplate} + } + + opts := DatabaseStatefulSetOptions{ + Replicas: 1, + Name: mdb.Name, + ServiceName: mdb.ServiceName(), + PodSpec: NewDefaultPodSpecWrapper(*mdb.Spec.PodSpec), + ServicePort: mdb.Spec.AdditionalMongodConfig.GetPortOrDefault(), + Persistent: mdb.Spec.Persistent, + OwnerReference: kube.BaseOwnerReference(&mdb), + AgentConfig: mdb.Spec.Agent, + StatefulSetSpecOverride: stsSpec, + MultiClusterMode: "false", + StsType: Standalone, + } + + for _, opt := range additionalOpts { + opt(&opts) + } + + return opts + } +} + +// ReplicaSetOptions returns a set of options which will configure a ReplicaSet StatefulSet +func ReplicaSetOptions(additionalOpts ...func(options *DatabaseStatefulSetOptions)) func(mdb mdbv1.MongoDB) DatabaseStatefulSetOptions { + return func(mdb mdbv1.MongoDB) DatabaseStatefulSetOptions { + var stsSpec *appsv1.StatefulSetSpec = nil + if mdb.Spec.PodSpec.PodTemplateWrapper.PodTemplate != nil { + stsSpec = &appsv1.StatefulSetSpec{Template: *mdb.Spec.PodSpec.PodTemplateWrapper.PodTemplate} + } + + opts := DatabaseStatefulSetOptions{ + Replicas: scale.ReplicasThisReconciliation(&mdb), + Name: mdb.Name, + ServiceName: mdb.ServiceName(), + Annotations: map[string]string{"type": "Replicaset"}, + PodSpec: NewDefaultPodSpecWrapper(*mdb.Spec.PodSpec), + ServicePort: mdb.Spec.AdditionalMongodConfig.GetPortOrDefault(), + Persistent: mdb.Spec.Persistent, + OwnerReference: kube.BaseOwnerReference(&mdb), + AgentConfig: mdb.Spec.Agent, + StatefulSetSpecOverride: stsSpec, + Labels: mdb.Labels, + MultiClusterMode: "false", + StsType: ReplicaSet, + } + + if mdb.Spec.DbCommonSpec.GetExternalDomain() != nil { + opts.HostNameOverrideConfigmapName = mdb.GetHostNameOverrideConfigmapName() + } + + for _, opt := range additionalOpts { + opt(&opts) + } + + return opts + } +} + +// ShardOptions returns a set of options which will configure single Shard StatefulSet +func ShardOptions(shardNum int, additionalOpts ...func(options *DatabaseStatefulSetOptions)) func(mdb mdbv1.MongoDB) DatabaseStatefulSetOptions { + return func(mdb mdbv1.MongoDB) DatabaseStatefulSetOptions { + var stsSpec *appsv1.StatefulSetSpec = nil + if mdb.Spec.ShardPodSpec.PodTemplateWrapper.PodTemplate != nil { + stsSpec = &appsv1.StatefulSetSpec{Template: *mdb.Spec.ShardPodSpec.PodTemplateWrapper.PodTemplate} + } + + // provide shard override per shard if specified + if mdb.Spec.ShardSpecificPodSpec != nil && len(mdb.Spec.ShardSpecificPodSpec) > shardNum { + shardOverride := mdb.Spec.ShardSpecificPodSpec[shardNum] + if shardOverride.PodTemplateWrapper.PodTemplate != nil { + stsSpec = &appsv1.StatefulSetSpec{Template: *shardOverride.PodTemplateWrapper.PodTemplate} + } + } + + opts := DatabaseStatefulSetOptions{ + Name: mdb.ShardRsName(shardNum), + ServiceName: mdb.ShardServiceName(), + PodSpec: NewDefaultPodSpecWrapper(*mdb.Spec.ShardPodSpec), + ServicePort: mdb.Spec.ShardSpec.GetAdditionalMongodConfig().GetPortOrDefault(), + OwnerReference: kube.BaseOwnerReference(&mdb), + AgentConfig: mdb.Spec.ShardSpec.GetAgentConfig(), + Persistent: mdb.Spec.Persistent, + StatefulSetSpecOverride: stsSpec, + Labels: mdb.Labels, + MultiClusterMode: "false", + StsType: Shard, + } + + for _, opt := range additionalOpts { + opt(&opts) + } + + return opts + } +} + +// ConfigServerOptions returns a set of options which will configure a Config Server StatefulSet +func ConfigServerOptions(additionalOpts ...func(options *DatabaseStatefulSetOptions)) func(mdb mdbv1.MongoDB) DatabaseStatefulSetOptions { + return func(mdb mdbv1.MongoDB) DatabaseStatefulSetOptions { + var stsSpec *appsv1.StatefulSetSpec = nil + if mdb.Spec.ConfigSrvPodSpec.PodTemplateWrapper.PodTemplate != nil { + stsSpec = &appsv1.StatefulSetSpec{Template: *mdb.Spec.ConfigSrvPodSpec.PodTemplateWrapper.PodTemplate} + } + + podSpecWrapper := NewDefaultPodSpecWrapper(*mdb.Spec.ConfigSrvPodSpec) + podSpecWrapper.Default.Persistence.SingleConfig.Storage = util.DefaultConfigSrvStorageSize + opts := DatabaseStatefulSetOptions{ + Name: mdb.ConfigRsName(), + ServiceName: mdb.ConfigSrvServiceName(), + PodSpec: podSpecWrapper, + ServicePort: mdb.Spec.ConfigSrvSpec.GetAdditionalMongodConfig().GetPortOrDefault(), + Persistent: mdb.Spec.Persistent, + OwnerReference: kube.BaseOwnerReference(&mdb), + AgentConfig: mdb.Spec.ConfigSrvSpec.GetAgentConfig(), + StatefulSetSpecOverride: stsSpec, + Labels: mdb.Labels, + MultiClusterMode: "false", + StsType: Config, + } + + for _, opt := range additionalOpts { + opt(&opts) + } + + return opts + } +} + +// MongosOptions returns a set of options which will configure a Mongos StatefulSet +func MongosOptions(additionalOpts ...func(options *DatabaseStatefulSetOptions)) func(mdb mdbv1.MongoDB) DatabaseStatefulSetOptions { + return func(mdb mdbv1.MongoDB) DatabaseStatefulSetOptions { + var stsSpec *appsv1.StatefulSetSpec = nil + if mdb.Spec.MongosPodSpec.PodTemplateWrapper.PodTemplate != nil { + stsSpec = &appsv1.StatefulSetSpec{Template: *mdb.Spec.MongosPodSpec.PodTemplateWrapper.PodTemplate} + } + + opts := DatabaseStatefulSetOptions{ + Name: mdb.MongosRsName(), + ServiceName: mdb.ServiceName(), + PodSpec: NewDefaultPodSpecWrapper(*mdb.Spec.MongosPodSpec), + ServicePort: mdb.Spec.MongosSpec.GetAdditionalMongodConfig().GetPortOrDefault(), + Persistent: util.BooleanRef(false), + OwnerReference: kube.BaseOwnerReference(&mdb), + AgentConfig: mdb.Spec.MongosSpec.GetAgentConfig(), + StatefulSetSpecOverride: stsSpec, + Labels: mdb.Labels, + MultiClusterMode: "false", + StsType: Mongos, + } + + for _, opt := range additionalOpts { + opt(&opts) + } + + return opts + } +} + +func DatabaseStatefulSet(mdb mdbv1.MongoDB, stsOptFunc func(mdb mdbv1.MongoDB) DatabaseStatefulSetOptions, log *zap.SugaredLogger) appsv1.StatefulSet { + stsOptions := stsOptFunc(mdb) + dbSts := DatabaseStatefulSetHelper(&mdb, &stsOptions, log) + + if len(stsOptions.Annotations) > 0 { + dbSts.Annotations = merge.StringToStringMap(dbSts.Annotations, stsOptions.Annotations) + } + + if len(stsOptions.Labels) > 0 { + dbSts.Labels = merge.StringToStringMap(dbSts.Labels, stsOptions.Labels) + } + + if stsOptions.StatefulSetSpecOverride != nil { + dbSts.Spec = merge.StatefulSetSpecs(dbSts.Spec, *stsOptions.StatefulSetSpecOverride) + } + + return dbSts +} + +func DatabaseStatefulSetHelper(mdb databaseStatefulSetSource, stsOpts *DatabaseStatefulSetOptions, log *zap.SugaredLogger) appsv1.StatefulSet { + allSources := getAllMongoDBVolumeSources(mdb, *stsOpts, nil) + + var extraEnvs []corev1.EnvVar + for _, source := range allSources { + if source.ShouldBeAdded() { + extraEnvs = append(extraEnvs, source.GetEnvs()...) + } + } + + stsOpts.ExtraEnvs = extraEnvs + + templateFunc := buildMongoDBPodTemplateSpec(*stsOpts) + return statefulset.New(buildDatabaseStatefulSetConfigurationFunction(mdb, templateFunc, *stsOpts, log)) +} + +// buildVaultDatabaseSecretsToInject fully constructs the DatabaseSecretsToInject required to +// convert to annotations to configure vault. +func buildVaultDatabaseSecretsToInject(mdb databaseStatefulSetSource, opts DatabaseStatefulSetOptions) vault.DatabaseSecretsToInject { + secretsToInject := vault.DatabaseSecretsToInject{Config: opts.VaultConfig} + if mdb.GetSecurity().ShouldUseClientCertificates() { + secretsToInject = vault.DatabaseSecretsToInject{Config: opts.VaultConfig} + } + + if mdb.GetSecurity().ShouldUseX509(opts.CurrentAgentAuthMode) || mdb.GetSecurity().ShouldUseClientCertificates() { + secretName := mdb.GetSecurity().AgentClientCertificateSecretName(mdb.GetName()).Name + secretName = fmt.Sprintf("%s%s", secretName, certs.OperatorGeneratedCertSuffix) + secretsToInject.AgentCerts = secretName + } + + if mdb.GetSecurity().GetInternalClusterAuthenticationMode() == util.X509 { + secretName := mdb.GetSecurity().InternalClusterAuthSecretName(opts.Name) + secretName = fmt.Sprintf("%s%s", secretName, certs.OperatorGeneratedCertSuffix) + secretsToInject.InternalClusterAuth = secretName + secretsToInject.InternalClusterHash = opts.InternalClusterHash + } + + // Enable prometheus injection + prom := mdb.GetPrometheus() + if prom != nil && prom.TLSSecretRef.Name != "" { + // Only need to inject Prometheus TLS cert on Vault. Already done for Secret backend. + secretsToInject.Prometheus = fmt.Sprintf("%s%s", prom.TLSSecretRef.Name, certs.OperatorGeneratedCertSuffix) + secretsToInject.PrometheusTLSCertHash = opts.PrometheusTLSCertHash + } + + // add vault specific annotations + secretsToInject.AgentApiKey = agents.ApiKeySecretName(opts.PodVars.ProjectID) + if mdb.GetSecurity().IsTLSEnabled() { + secretName := mdb.GetSecurity().MemberCertificateSecretName(opts.Name) + secretsToInject.MemberClusterAuth = fmt.Sprintf("%s%s", secretName, certs.OperatorGeneratedCertSuffix) + secretsToInject.MemberClusterHash = opts.CertificateHash + } + return secretsToInject +} + +// buildDatabaseStatefulSetConfigurationFunction returns the function that will modify the StatefulSet +func buildDatabaseStatefulSetConfigurationFunction(mdb databaseStatefulSetSource, podTemplateSpecFunc podtemplatespec.Modification, opts DatabaseStatefulSetOptions, log *zap.SugaredLogger) statefulset.Modification { + podLabels := map[string]string{ + appLabelKey: opts.ServiceName, + ControllerLabelName: util.OperatorName, + PodAntiAffinityLabelKey: opts.Name, + } + + configurePodSpecSecurityContext, configureContainerSecurityContext := podtemplatespec.WithDefaultSecurityContextsModifications() + + configureImagePullSecrets := podtemplatespec.NOOP() + name, found := env.Read(util.ImagePullSecrets) + if found { + configureImagePullSecrets = podtemplatespec.WithImagePullSecrets(name) + } + + secretsToInject := buildVaultDatabaseSecretsToInject(mdb, opts) + volumes, volumeMounts := getVolumesAndVolumeMounts(mdb, opts, secretsToInject.AgentCerts, secretsToInject.InternalClusterAuth) + + allSources := getAllMongoDBVolumeSources(mdb, opts, log) + for _, source := range allSources { + if source.ShouldBeAdded() { + volumes = append(volumes, source.GetVolumes()...) + volumeMounts = append(volumeMounts, source.GetVolumeMounts()...) + } + } + + var mounts []corev1.VolumeMount + var pvcFuncs map[string]persistentvolumeclaim.Modification + if opts.Persistent == nil || *opts.Persistent { + pvcFuncs, mounts = buildPersistentVolumeClaimsFuncs(opts) + volumeMounts = append(volumeMounts, mounts...) + } else { + volumes, volumeMounts = GetNonPersistentMongoDBVolumeMounts(volumes, volumeMounts) + } + + volumesFunc := func(spec *corev1.PodTemplateSpec) { + for _, v := range volumes { + podtemplatespec.WithVolume(v)(spec) + } + } + + keys := make([]string, 0, len(pvcFuncs)) + for k := range pvcFuncs { + keys = append(keys, k) + } + + // ensure consistent order of PVCs + sort.Strings(keys) + + volumeClaimFuncs := func(sts *appsv1.StatefulSet) { + for _, name := range keys { + statefulset.WithVolumeClaim(name, pvcFuncs[name])(sts) + } + } + + ssLabels := map[string]string{ + appLabelKey: opts.ServiceName, + } + + annotationFunc := statefulset.WithAnnotations(defaultPodAnnotations(opts.CertificateHash)) + podTemplateAnnotationFunc := podtemplatespec.NOOP() + + annotationFunc = statefulset.Apply( + annotationFunc, + statefulset.WithAnnotations(map[string]string{util.InternalCertAnnotationKey: opts.InternalClusterHash}), + ) + + if vault.IsVaultSecretBackend() { + podTemplateAnnotationFunc = podtemplatespec.Apply(podTemplateAnnotationFunc, podtemplatespec.WithAnnotations(secretsToInject.DatabaseAnnotations(mdb.GetNamespace()))) + } + + stsName := opts.Name + podAffinity := mdb.GetName() + if opts.StatefulSetNameOverride != "" { + stsName = opts.StatefulSetNameOverride + podAffinity = opts.StatefulSetNameOverride + } + + return statefulset.Apply( + statefulset.WithLabels(ssLabels), + statefulset.WithName(stsName), + statefulset.WithNamespace(mdb.GetNamespace()), + statefulset.WithMatchLabels(podLabels), + statefulset.WithServiceName(opts.ServiceName), + statefulset.WithReplicas(opts.Replicas), + statefulset.WithOwnerReference(opts.OwnerReference), + annotationFunc, + volumeClaimFuncs, + statefulset.WithPodSpecTemplate(podtemplatespec.Apply( + podTemplateAnnotationFunc, + podtemplatespec.WithAffinity(podAffinity, PodAntiAffinityLabelKey, 100), + podtemplatespec.WithTerminationGracePeriodSeconds(util.DefaultPodTerminationPeriodSeconds), + podtemplatespec.WithPodLabels(podLabels), + podtemplatespec.WithContainerByIndex(0, sharedDatabaseContainerFunc(*opts.PodSpec, volumeMounts, configureContainerSecurityContext, opts.ServicePort)), + volumesFunc, + configurePodSpecSecurityContext, + configureImagePullSecrets, + podTemplateSpecFunc, + )), + ) +} + +func buildPersistentVolumeClaimsFuncs(opts DatabaseStatefulSetOptions) (map[string]persistentvolumeclaim.Modification, []corev1.VolumeMount) { + var claims map[string]persistentvolumeclaim.Modification + var mounts []corev1.VolumeMount + + podSpec := opts.PodSpec + // if persistence not set or if single one is set + if podSpec.Persistence == nil || + (podSpec.Persistence.SingleConfig == nil && podSpec.Persistence.MultipleConfig == nil) || + podSpec.Persistence.SingleConfig != nil { + var config *mdbv1.PersistenceConfig + if podSpec.Persistence != nil && podSpec.Persistence.SingleConfig != nil { + config = podSpec.Persistence.SingleConfig + } + // Single claim, multiple mounts using this claim. Note, that we use "subpaths" in the volume to mount to different + // physical folders + claims, mounts = createClaimsAndMountsSingleModeFunc(config, opts) + } else if podSpec.Persistence.MultipleConfig != nil { + defaultConfig := *podSpec.Default.Persistence.MultipleConfig + + // Multiple claims, multiple mounts. No subpaths are used and everything is mounted to the root of directory + claims, mounts = createClaimsAndMountsMultiModeFunc(opts.PodSpec.Persistence, defaultConfig, opts.Labels) + } + return claims, mounts +} + +func sharedDatabaseContainerFunc(podSpecWrapper mdbv1.PodSpecWrapper, volumeMounts []corev1.VolumeMount, configureContainerSecurityContext container.Modification, port int32) container.Modification { + return container.Apply( + container.WithResourceRequirements(buildRequirementsFromPodSpec(podSpecWrapper)), + container.WithPorts([]corev1.ContainerPort{{ContainerPort: port}}), + container.WithImagePullPolicy(corev1.PullPolicy(env.ReadOrPanic(util.AutomationAgentImagePullPolicy))), + container.WithImage(env.ReadOrPanic(util.AutomationAgentImage)), + container.WithVolumeMounts(volumeMounts), + container.WithLivenessProbe(DatabaseLivenessProbe()), + container.WithReadinessProbe(DatabaseReadinessProbe()), + container.WithStartupProbe(DatabaseStartupProbe()), + configureContainerSecurityContext, + ) +} + +// getTLSPrometheusVolumeAndVolumeMount mounts Prometheus TLS Volumes from Secrets. +// +// These volumes will only be mounted when TLS has been enabled. They will +// contain the concatenated (PEM-format) certificates; which have been created +// by the Operator prior to this. +// +// Important: This function will not return Secret mounts in case of Vault backend! +// +// The Prometheus TLS Secret name is configured in: +// +// `spec.prometheus.tlsSecretRef.Name` +// +// The Secret will be mounted in: +// `/var/lib/mongodb-automation/secrets/prometheus`. +func getTLSPrometheusVolumeAndVolumeMount(prom *mdbcv1.Prometheus) ([]corev1.Volume, []corev1.VolumeMount) { + volumes := []corev1.Volume{} + volumeMounts := []corev1.VolumeMount{} + + if prom == nil || vault.IsVaultSecretBackend() { + return volumes, volumeMounts + } + + secretFunc := func(v *corev1.Volume) { v.Secret.Optional = util.BooleanRef(true) } + // Name of the Secret (PEM-format) with the concatenation of the certificate and key. + secretName := prom.TLSSecretRef.Name + certs.OperatorGeneratedCertSuffix + + secretVolume := statefulset.CreateVolumeFromSecret(util.PrometheusSecretVolumeName, secretName, secretFunc) + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + MountPath: util.SecretVolumeMountPathPrometheus, + Name: secretVolume.Name, + }) + volumes = append(volumes, secretVolume) + + return volumes, volumeMounts +} + +// getAllMongoDBVolumeSources returns a slice of MongoDBVolumeSource. These are used to determine which volumes +// and volume mounts should be added to the StatefulSet. +func getAllMongoDBVolumeSources(mdb databaseStatefulSetSource, databaseOpts DatabaseStatefulSetOptions, log *zap.SugaredLogger) []MongoDBVolumeSource { + if log == nil { + log = zap.S() + } + caVolume := &caVolumeSource{ + opts: databaseOpts, + logger: log, + } + tlsVolume := &tlsVolumeSource{ + security: mdb.GetSecurity(), + databaseOpts: databaseOpts, + logger: log, + } + + var allVolumeSources []MongoDBVolumeSource + allVolumeSources = append(allVolumeSources, caVolume) + allVolumeSources = append(allVolumeSources, tlsVolume) + + return allVolumeSources +} + +// getVolumesAndVolumeMounts returns all volumes and mounts required for the StatefulSet. +func getVolumesAndVolumeMounts(mdb databaseStatefulSetSource, databaseOpts DatabaseStatefulSetOptions, agentCertsSecretName, internalClusterAuthSecretName string) ([]corev1.Volume, []corev1.VolumeMount) { + var volumesToAdd []corev1.Volume + var volumeMounts []corev1.VolumeMount + + prometheusVolumes, prometheusVolumeMounts := getTLSPrometheusVolumeAndVolumeMount(mdb.GetPrometheus()) + + volumesToAdd = append(volumesToAdd, prometheusVolumes...) + volumeMounts = append(volumeMounts, prometheusVolumeMounts...) + + if !vault.IsVaultSecretBackend() && mdb.GetSecurity().ShouldUseX509(databaseOpts.CurrentAgentAuthMode) || mdb.GetSecurity().ShouldUseClientCertificates() { + agentSecretVolume := statefulset.CreateVolumeFromSecret(util.AgentSecretName, agentCertsSecretName) + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + MountPath: agentCertMountPath, + Name: agentSecretVolume.Name, + ReadOnly: true, + }) + volumesToAdd = append(volumesToAdd, agentSecretVolume) + } + + // add volume for x509 cert used in internal cluster authentication + if !vault.IsVaultSecretBackend() && mdb.GetSecurity().GetInternalClusterAuthenticationMode() == util.X509 { + internalClusterAuthVolume := statefulset.CreateVolumeFromSecret(util.ClusterFileName, internalClusterAuthSecretName) + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + MountPath: util.InternalClusterAuthMountPath, + Name: internalClusterAuthVolume.Name, + ReadOnly: true, + }) + volumesToAdd = append(volumesToAdd, internalClusterAuthVolume) + } + + if !vault.IsVaultSecretBackend() { + volumesToAdd = append(volumesToAdd, statefulset.CreateVolumeFromSecret(AgentAPIKeyVolumeName, agents.ApiKeySecretName(databaseOpts.PodVars.ProjectID))) + volumeMounts = append(volumeMounts, statefulset.CreateVolumeMount(AgentAPIKeyVolumeName, AgentAPIKeySecretPath)) + } + + volumesToAdd, volumeMounts = GetNonPersistentAgentVolumeMounts(volumesToAdd, volumeMounts) + + return volumesToAdd, volumeMounts +} + +// buildMongoDBPodTemplateSpec constructs the podTemplateSpec for the MongoDB resource +func buildMongoDBPodTemplateSpec(opts DatabaseStatefulSetOptions) podtemplatespec.Modification { + // Database image version, should be a specific version to avoid using stale 'non-empty' versions (before versioning) + databaseImageVersion := env.ReadOrDefault(DatabaseVersionEnv, "latest") + databaseImageUrl := ContainerImage(util.AutomationAgentImage, databaseImageVersion, nil) + + // scripts volume is shared by the init container and the AppDB so the startup + // script can be copied over + scriptsVolume := statefulset.CreateVolumeFromEmptyDir("database-scripts") + databaseScriptsVolumeMount := databaseScriptsVolumeMount(true) + + volumes := []corev1.Volume{scriptsVolume} + volumeMounts := []corev1.VolumeMount{databaseScriptsVolumeMount} + initContainerModifications := []func(*corev1.Container){buildDatabaseInitContainer()} + databaseContainerModifications := []func(*corev1.Container){container.Apply( + container.WithName(util.DatabaseContainerName), + container.WithImage(databaseImageUrl), + container.WithEnvs(databaseEnvVars(opts)...), + container.WithCommand([]string{"/opt/scripts/agent-launcher.sh"}), + container.WithVolumeMounts(volumeMounts), + )} + if opts.HostNameOverrideConfigmapName != "" { + volumes = append(volumes, statefulset.CreateVolumeFromConfigMap(opts.HostNameOverrideConfigmapName, opts.HostNameOverrideConfigmapName)) + modification := container.WithVolumeMounts([]corev1.VolumeMount{ + { + Name: opts.HostNameOverrideConfigmapName, + MountPath: "/opt/scripts/config", + }, + }) + initContainerModifications = append(initContainerModifications, modification) + databaseContainerModifications = append(databaseContainerModifications, modification) + } + + serviceAccountName := getServiceAccountName(opts) + + return podtemplatespec.Apply( + sharedDatabaseConfiguration(opts), + podtemplatespec.WithServiceAccount(util.MongoDBServiceAccount), + podtemplatespec.WithServiceAccount(serviceAccountName), + podtemplatespec.WithVolumes(volumes), + podtemplatespec.WithInitContainerByIndex(0, initContainerModifications...), + podtemplatespec.WithContainerByIndex(0, databaseContainerModifications...), + ) + +} + +// getServiceAccountName returns the serviceAccount to be used by the mongoDB pod, +// it uses the "serviceAccountName" specified in the podSpec of CR, if it's not specified returns +// the default serviceAccount name +func getServiceAccountName(opts DatabaseStatefulSetOptions) string { + podSpec := opts.PodSpec + + if podSpec != nil && podSpec.PodTemplateWrapper.PodTemplate != nil { + if podSpec.PodTemplateWrapper.PodTemplate.Spec.ServiceAccountName != "" { + return podSpec.PodTemplateWrapper.PodTemplate.Spec.ServiceAccountName + } + } + + return util.MongoDBServiceAccount +} + +// sharedDatabaseConfiguration is a function which applies all the shared configuration +// between the appDb and MongoDB resources +func sharedDatabaseConfiguration(opts DatabaseStatefulSetOptions) podtemplatespec.Modification { + configurePodSpecSecurityContext, configureContainerSecurityContext := podtemplatespec.WithDefaultSecurityContextsModifications() + + pullSecretsConfigurationFunc := podtemplatespec.NOOP() + if pullSecrets, ok := env.Read(util.ImagePullSecrets); ok { + pullSecretsConfigurationFunc = podtemplatespec.WithImagePullSecrets(pullSecrets) + } + + return podtemplatespec.Apply( + podtemplatespec.WithPodLabels(defaultPodLabels(opts.ServiceName, opts.Name)), + podtemplatespec.WithTerminationGracePeriodSeconds(util.DefaultPodTerminationPeriodSeconds), + pullSecretsConfigurationFunc, + configurePodSpecSecurityContext, + podtemplatespec.WithAffinity(opts.Name, PodAntiAffinityLabelKey, 100), + podtemplatespec.WithTopologyKey(opts.PodSpec.GetTopologyKeyOrDefault(), 0), + podtemplatespec.WithContainerByIndex(0, + container.Apply( + container.WithResourceRequirements(buildRequirementsFromPodSpec(*opts.PodSpec)), + container.WithPorts([]corev1.ContainerPort{{ContainerPort: opts.ServicePort}}), + container.WithImagePullPolicy(corev1.PullPolicy(env.ReadOrPanic(util.AutomationAgentImagePullPolicy))), + container.WithLivenessProbe(DatabaseLivenessProbe()), + container.WithEnvs(startupParametersToAgentFlag(opts.AgentConfig.StartupParameters)), + configureContainerSecurityContext, + ), + ), + ) +} + +// StartupParametersToAgentFlag takes a map representing key-value paris +// of startup parameters +// and concatenates them into a single string that is then +// returned as env variable AGENT_FLAGS +func startupParametersToAgentFlag(parameters mdbv1.StartupParameters) corev1.EnvVar { + agentParams := "" + for key, value := range defaultAgentParameters() { + if _, ok := parameters[key]; !ok { + // add the default parameter + agentParams += "-" + key + "," + value + "," + } + // Skip as this has it will be set by custom flags + } + for key, value := range parameters { + // Using comma as delimiter to split the string later + // in the agentlauncher script + agentParams += "-" + key + "," + value + "," + } + + return corev1.EnvVar{Name: "AGENT_FLAGS", Value: agentParams} +} + +func defaultAgentParameters() mdbv1.StartupParameters { + return map[string]string{"logFile": "/var/log/mongodb-mms-automation/automation-agent.log"} +} + +// databaseScriptsVolumeMount constructs the VolumeMount for the Database scripts +// this should be readonly for the Database, and not read only for the init container. +func databaseScriptsVolumeMount(readOnly bool) corev1.VolumeMount { + return corev1.VolumeMount{ + Name: PvcNameDatabaseScripts, + MountPath: PvcMountPathScripts, + ReadOnly: readOnly, + } +} + +// buildDatabaseInitContainer builds the container specification for mongodb-enterprise-init-database image +func buildDatabaseInitContainer() container.Modification { + version := env.ReadOrDefault(InitDatabaseVersionEnv, "latest") + initContainerImageURL := ContainerImage(util.InitDatabaseImageUrlEnv, version, nil) + + return container.Apply( + container.WithName(InitDatabaseContainerName), + container.WithImage(initContainerImageURL), + container.WithVolumeMounts([]corev1.VolumeMount{ + databaseScriptsVolumeMount(false), + }), + ) +} + +func databaseEnvVars(opts DatabaseStatefulSetOptions) []corev1.EnvVar { + podVars := opts.PodVars + if podVars == nil { + return []corev1.EnvVar{} + } + vars := []corev1.EnvVar{ + { + Name: util.EnvVarLogLevel, + Value: podVars.LogLevel, + }, + { + Name: util.EnvVarBaseUrl, + Value: podVars.BaseURL, + }, + { + Name: util.EnvVarProjectId, + Value: podVars.ProjectID, + }, + { + Name: util.EnvVarUser, + Value: podVars.User, + }, + { + Name: util.EnvVarMultiClusterMode, + Value: opts.MultiClusterMode, + }, + } + + if opts.PodVars.SSLRequireValidMMSServerCertificates { + vars = append(vars, + corev1.EnvVar{ + Name: util.EnvVarSSLRequireValidMMSCertificates, + Value: strconv.FormatBool(opts.PodVars.SSLRequireValidMMSServerCertificates), + }, + ) + } + + // This is only used for debugging + if useDebugAgent := os.Getenv(util.EnvVarDebug); useDebugAgent != "" { + vars = append(vars, corev1.EnvVar{Name: util.EnvVarDebug, Value: useDebugAgent}) + } + + // append any additional env vars specified. + vars = append(vars, opts.ExtraEnvs...) + + return vars +} + +func DatabaseLivenessProbe() probes.Modification { + return probes.Apply( + probes.WithExecCommand([]string{databaseLivenessProbeCommand}), + probes.WithInitialDelaySeconds(10), + probes.WithTimeoutSeconds(30), + probes.WithPeriodSeconds(30), + probes.WithSuccessThreshold(1), + probes.WithFailureThreshold(6), + ) +} + +func DatabaseReadinessProbe() probes.Modification { + return probes.Apply( + probes.WithExecCommand([]string{databaseReadinessProbeCommand}), + probes.WithFailureThreshold(4), + probes.WithInitialDelaySeconds(5), + probes.WithPeriodSeconds(5), + ) +} + +func DatabaseStartupProbe() probes.Modification { + return probes.Apply( + probes.WithExecCommand([]string{databaseLivenessProbeCommand}), + probes.WithInitialDelaySeconds(1), + probes.WithTimeoutSeconds(30), + probes.WithPeriodSeconds(20), + probes.WithSuccessThreshold(1), + probes.WithFailureThreshold(10), + ) +} + +func defaultPodAnnotations(certHash string) map[string]string { + return map[string]string{ + // this annotation is necessary in order to trigger a pod restart + // if the certificate secret is out of date. This happens if + // existing certificates have been replaced/rotated/renewed. + certs.CertHashAnnotationKey: certHash, + } +} + +// TODO: temprorary duplication to avoid circular imports +func NewDefaultPodSpecWrapper(podSpec mdbv1.MongoDbPodSpec) *mdbv1.PodSpecWrapper { + return &mdbv1.PodSpecWrapper{ + MongoDbPodSpec: podSpec, + Default: newDefaultPodSpec(), + } +} + +func newDefaultPodSpec() mdbv1.MongoDbPodSpec { + podSpecWrapper := mdbv1.NewEmptyPodSpecWrapperBuilder(). + SetPodAntiAffinityTopologyKey(util.DefaultAntiAffinityTopologyKey). + SetSinglePersistence(mdbv1.NewPersistenceBuilder(util.DefaultMongodStorageSize)). + SetMultiplePersistence(mdbv1.NewPersistenceBuilder(util.DefaultMongodStorageSize), + mdbv1.NewPersistenceBuilder(util.DefaultJournalStorageSize), + mdbv1.NewPersistenceBuilder(util.DefaultLogsStorageSize)). + Build() + + return podSpecWrapper.MongoDbPodSpec +} + +// GetNonPersistentMongoDBVolumeMounts returns two arrays of non-persistent, empty volumes and corresponding mounts for the database container. +func GetNonPersistentMongoDBVolumeMounts(volumes []corev1.Volume, volumeMounts []corev1.VolumeMount) ([]corev1.Volume, []corev1.VolumeMount) { + volumes = append(volumes, statefulset.CreateVolumeFromEmptyDir(util.PvcNameData)) + + volumeMounts = append(volumeMounts, statefulset.CreateVolumeMount(util.PvcNameData, util.PvcMountPathData, statefulset.WithSubPath(util.PvcNameData))) + volumeMounts = append(volumeMounts, statefulset.CreateVolumeMount(util.PvcNameData, util.PvcMountPathJournal, statefulset.WithSubPath(util.PvcNameJournal))) + volumeMounts = append(volumeMounts, statefulset.CreateVolumeMount(util.PvcNameData, util.PvcMountPathLogs, statefulset.WithSubPath(util.PvcNameLogs))) + + return volumes, volumeMounts +} + +// GetNonPersistentAgentVolumeMounts returns two arrays of non-persistent, empty volumes and corresponding mounts for the Agent container. +func GetNonPersistentAgentVolumeMounts(volumes []corev1.Volume, volumeMounts []corev1.VolumeMount) ([]corev1.Volume, []corev1.VolumeMount) { + volumes = append(volumes, statefulset.CreateVolumeFromEmptyDir(util.PvMms)) + + // The agent reads and writes into its own directory. It also contains a subdirectory called downloads. + // This one is published by the Dockerfile + volumeMounts = append(volumeMounts, statefulset.CreateVolumeMount(util.PvMms, util.PvcMmsMountPath, statefulset.WithSubPath(util.PvcMms))) + + // Runtime data for MMS + volumeMounts = append(volumeMounts, statefulset.CreateVolumeMount(util.PvMms, util.PvcMmsHomeMountPath, statefulset.WithSubPath(util.PvcMmsHome))) + + volumeMounts = append(volumeMounts, statefulset.CreateVolumeMount(util.PvMms, util.PvcMountPathTmp, statefulset.WithSubPath(util.PvcNameTmp))) + return volumes, volumeMounts +} + +// replaceImageTagOrDigestToTag returns the image with the tag or digest replaced to a given version +func replaceImageTagOrDigestToTag(image string, newVersion string) string { + // example: quay.io/mongodb/mongodb-agent@sha256:6a82abae27c1ba1133f3eefaad71ea318f8fa87cc57fe9355d6b5b817ff97f1a + if strings.Contains(image, "sha256:") { + imageSplit := strings.Split(image, "@") + imageSplit[len(imageSplit)-1] = newVersion + return strings.Join(imageSplit, ":") + } else { + // examples: + // - quay.io/mongodb/mongodb-agent:1234-567 + // - private-registry.local:3000/mongodb/mongodb-agent:1234-567 + // - mongodb + idx := strings.IndexRune(image, '/') + // If there is no domain separator in the image string or the segment before the slash does not contain + // a '.' or ':' and is not 'localhost' to indicate that the segment is a host, assume the image will be pulled from + // docker.io and the whole string represents the image. + if idx == -1 || (!strings.ContainsAny(image[:idx], ".:") && image[:idx] != "localhost") { + return fmt.Sprintf("%s:%s", image[:strings.LastIndex(image, ":")], newVersion) + } + + host := image[:idx] + imagePath := image[idx+1:] + + // If there is a ':' in the image path we can safely assume that it is a version separator. + if strings.Contains(imagePath, ":") { + imagePath = imagePath[:strings.LastIndex(imagePath, ":")] + } + return fmt.Sprintf("%s/%s:%s", host, imagePath, newVersion) + } +} + +// ContainerImage builds container image using image environment variable imageURLEnv and version. +// It handles image digests when running in disconnected environment in OpenShift, where images +// are referenced by sha256 digest instead of tags. +// It works by convention by looking up RELATED_IMAGE_{imageURLEnv}_{version_underscored}. +// RELATED_IMAGE_* env variables are set in Helm chart for OpenShift. +// In case there is no RELATED_IMAGE defined it replaces digest or tag to version. +func ContainerImage(imageURLEnv string, version string, retrieveImageURL func() string) string { + versionUnderscored := strings.ReplaceAll(version, ".", "_") + versionUnderscored = strings.ReplaceAll(versionUnderscored, "-", "_") + relatedImageEnv := fmt.Sprintf("RELATED_IMAGE_%s_%s", imageURLEnv, versionUnderscored) + + if relatedImage := os.Getenv(relatedImageEnv); relatedImage != "" { + return relatedImage + } + + var imageURL string + if retrieveImageURL != nil { + imageURL = retrieveImageURL() + } else { + imageURL = os.Getenv(imageURLEnv) + } + + if strings.Contains(imageURL, ":") { + // here imageURL is not a host only but also with version or digest + // in that case we need to replace the version/digest. + // This is case with AGENT_IMAGE env variable which is provided as a full URL with version + // and not as a pair of host and version. + // In case AGENT_IMAGE is full URL with digest it will be replaced to given tag version, + // but most probably if it has digest, there is also RELATED_IMAGE defined, which will be picked up first. + return replaceImageTagOrDigestToTag(imageURL, version) + } + + return fmt.Sprintf("%s:%s", imageURL, version) +} diff --git a/controllers/operator/construct/database_construction_test.go b/controllers/operator/construct/database_construction_test.go new file mode 100644 index 000000000..c68312f60 --- /dev/null +++ b/controllers/operator/construct/database_construction_test.go @@ -0,0 +1,329 @@ +package construct + +import ( + "fmt" + "os" + "path" + "testing" + "time" + + "github.com/mongodb/mongodb-kubernetes-operator/controllers/construct" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/mock" + + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/stretchr/testify/assert" + "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" +) + +func init() { + logger, _ := zap.NewDevelopment() + zap.ReplaceGlobals(logger) + mock.InitDefaultEnvVariables() +} + +func Test_buildDatabaseInitContainer(t *testing.T) { + modification := buildDatabaseInitContainer() + container := &corev1.Container{} + modification(container) + expectedVolumeMounts := []corev1.VolumeMount{{ + Name: PvcNameDatabaseScripts, + MountPath: PvcMountPathScripts, + ReadOnly: false, + }} + expectedContainer := &corev1.Container{ + Name: InitDatabaseContainerName, + Image: "quay.io/mongodb/mongodb-enterprise-init-database:latest", + VolumeMounts: expectedVolumeMounts, + } + assert.Equal(t, expectedContainer, container) + +} + +func TestStatefulsetCreationPanicsIfEnvVariablesAreNotSet(t *testing.T) { + t.Run("Empty Agent Image", func(t *testing.T) { + defer mock.InitDefaultEnvVariables() + os.Setenv(util.AutomationAgentImage, "") + rs := mdbv1.NewReplicaSetBuilder().Build() + assert.Panics(t, func() { + DatabaseStatefulSet(*rs, ReplicaSetOptions(GetPodEnvOptions()), nil) + }) + }) + + t.Run("Empty Image Pull Policy", func(t *testing.T) { + defer mock.InitDefaultEnvVariables() + os.Setenv(util.AutomationAgentImagePullPolicy, "") + sc := mdbv1.NewClusterBuilder().Build() + assert.Panics(t, func() { + DatabaseStatefulSet(*sc, ShardOptions(0), nil) + }) + assert.Panics(t, func() { + DatabaseStatefulSet(*sc, ConfigServerOptions(), nil) + }) + assert.Panics(t, func() { + DatabaseStatefulSet(*sc, MongosOptions(), nil) + }) + }) +} + +func TestStatefulsetCreationSuccessful(t *testing.T) { + start := time.Now() + rs := mdbv1.NewReplicaSetBuilder().Build() + + _ = DatabaseStatefulSet(*rs, ReplicaSetOptions(GetPodEnvOptions()), nil) + assert.True(t, time.Since(start) < time.Second*4) // we waited only a little (considering 2 seconds of wait as well) +} + +func TestDatabaseEnvVars(t *testing.T) { + envVars := defaultPodVars() + opts := DatabaseStatefulSetOptions{PodVars: envVars} + podEnv := databaseEnvVars(opts) + assert.Len(t, podEnv, 5) + + envVars = defaultPodVars() + envVars.SSLRequireValidMMSServerCertificates = true + opts = DatabaseStatefulSetOptions{PodVars: envVars} + + podEnv = databaseEnvVars(opts) + assert.Len(t, podEnv, 6) + assert.Equal(t, podEnv[5], corev1.EnvVar{ + Name: util.EnvVarSSLRequireValidMMSCertificates, + Value: "true", + }) + + envVars = defaultPodVars() + envVars.SSLMMSCAConfigMap = "custom-ca" + v := &caVolumeSource{} + extraEnvs := v.GetEnvs() + + opts = DatabaseStatefulSetOptions{PodVars: envVars, ExtraEnvs: extraEnvs} + trustedCACertLocation := path.Join(caCertMountPath, util.CaCertMMS) + podEnv = databaseEnvVars(opts) + assert.Len(t, podEnv, 6) + assert.Equal(t, podEnv[5], corev1.EnvVar{ + Name: util.EnvVarSSLTrustedMMSServerCertificate, + Value: trustedCACertLocation, + }) + + envVars = defaultPodVars() + envVars.SSLRequireValidMMSServerCertificates = true + envVars.SSLMMSCAConfigMap = "custom-ca" + opts = DatabaseStatefulSetOptions{PodVars: envVars, ExtraEnvs: extraEnvs} + podEnv = databaseEnvVars(opts) + assert.Len(t, podEnv, 7) + assert.Equal(t, podEnv[6], corev1.EnvVar{ + Name: util.EnvVarSSLTrustedMMSServerCertificate, + Value: trustedCACertLocation, + }) + assert.Equal(t, podEnv[5], corev1.EnvVar{ + Name: util.EnvVarSSLRequireValidMMSCertificates, + Value: "true", + }) + +} + +func TestAgentFlags(t *testing.T) { + agentStartupParameters := mdbv1.StartupParameters{ + "Key1": "Value1", + "Key2": "Value2", + } + + mdb := mdbv1.NewReplicaSetBuilder().SetAgentConfig(mdbv1.AgentConfig{StartupParameters: agentStartupParameters}).Build() + sts := DatabaseStatefulSet(*mdb, ReplicaSetOptions(GetPodEnvOptions()), nil) + variablesMap := env.ToMap(sts.Spec.Template.Spec.Containers[0].Env...) + val, ok := variablesMap["AGENT_FLAGS"] + assert.True(t, ok) + assert.Contains(t, val, "-Key1,Value1", "-Key2,Value2") + +} + +func TestLabelsAndAnotations(t *testing.T) { + labels := map[string]string{"l1": "val1", "l2": "val2"} + annotations := map[string]string{"a1": "val1", "a2": "val2"} + + mdb := mdbv1.NewReplicaSetBuilder().SetAnnotations(annotations).SetLabels(labels).Build() + sts := DatabaseStatefulSet(*mdb, ReplicaSetOptions(GetPodEnvOptions()), nil) + + // add the default label to the map + labels["app"] = "test-mdb-svc" + assert.Equal(t, labels, sts.Labels) +} + +func TestReplaceImageTagOrDigestToTag(t *testing.T) { + assert.Equal(t, "quay.io/mongodb/mongodb-agent:9876-54321", replaceImageTagOrDigestToTag("quay.io/mongodb/mongodb-agent:1234-567", "9876-54321")) + assert.Equal(t, "docker.io/mongodb/mongodb-enterprise-server:9876-54321", replaceImageTagOrDigestToTag("docker.io/mongodb/mongodb-enterprise-server:1234-567", "9876-54321")) + assert.Equal(t, "quay.io/mongodb/mongodb-agent:9876-54321", replaceImageTagOrDigestToTag("quay.io/mongodb/mongodb-agent@sha256:6a82abae27c1ba1133f3eefaad71ea318f8fa87cc57fe9355d6b5b817ff97f1a", "9876-54321")) + assert.Equal(t, "quay.io/mongodb/mongodb-enterprise-database:some-tag", replaceImageTagOrDigestToTag("quay.io/mongodb/mongodb-enterprise-database:45678", "some-tag")) + assert.Equal(t, "quay.io:3000/mongodb/mongodb-enterprise-database:some-tag", replaceImageTagOrDigestToTag("quay.io:3000/mongodb/mongodb-enterprise-database:45678", "some-tag")) +} + +func TestContainerImage(t *testing.T) { + initDatabaseRelatedImageEnv1 := fmt.Sprintf("RELATED_IMAGE_%s_1_0_0", InitDatabaseVersionEnv) + initDatabaseRelatedImageEnv2 := fmt.Sprintf("RELATED_IMAGE_%s_12_0_4_7554_1", InitDatabaseVersionEnv) + initDatabaseRelatedImageEnv3 := fmt.Sprintf("RELATED_IMAGE_%s_2_0_0_b20220912000000", InitDatabaseVersionEnv) + + t.Setenv(InitDatabaseVersionEnv, "quay.io/mongodb/mongodb-enterprise-init-database") + t.Setenv(initDatabaseRelatedImageEnv1, "quay.io/mongodb/mongodb-enterprise-init-database@sha256:608daf56296c10c9bd02cc85bb542a849e9a66aff0697d6359b449540696b1fd") + t.Setenv(initDatabaseRelatedImageEnv2, "quay.io/mongodb/mongodb-enterprise-init-database@sha256:b631ee886bb49ba8d7b90bb003fe66051dadecbc2ac126ac7351221f4a7c377c") + t.Setenv(initDatabaseRelatedImageEnv3, "quay.io/mongodb/mongodb-enterprise-init-database@sha256:f1a7f49cd6533d8ca9425f25cdc290d46bb883997f07fac83b66cc799313adad") + + // there is no related image for 0.0.1 + assert.Equal(t, "quay.io/mongodb/mongodb-enterprise-init-database:0.0.1", ContainerImage(InitDatabaseVersionEnv, "0.0.1", nil)) + // for 10.2.25.6008-1 there is no RELATED_IMAGE variable set, so we use input instead of digest + assert.Equal(t, "quay.io/mongodb/mongodb-enterprise-init-database:10.2.25.6008-1", ContainerImage(InitDatabaseVersionEnv, "10.2.25.6008-1", nil)) + // for following versions we set RELATED_IMAGE_MONGODB_IMAGE_* env variables to sha256 digest + assert.Equal(t, "quay.io/mongodb/mongodb-enterprise-init-database@sha256:608daf56296c10c9bd02cc85bb542a849e9a66aff0697d6359b449540696b1fd", ContainerImage(InitDatabaseVersionEnv, "1.0.0", nil)) + assert.Equal(t, "quay.io/mongodb/mongodb-enterprise-init-database@sha256:b631ee886bb49ba8d7b90bb003fe66051dadecbc2ac126ac7351221f4a7c377c", ContainerImage(InitDatabaseVersionEnv, "12.0.4.7554-1", nil)) + assert.Equal(t, "quay.io/mongodb/mongodb-enterprise-init-database@sha256:f1a7f49cd6533d8ca9425f25cdc290d46bb883997f07fac83b66cc799313adad", ContainerImage(InitDatabaseVersionEnv, "2.0.0-b20220912000000", nil)) + + // env var has input already, so it is replaced + t.Setenv(util.InitAppdbImageUrlEnv, "quay.io/mongodb/mongodb-enterprise-init-appdb:12.0.4.7554-1") + assert.Equal(t, "quay.io/mongodb/mongodb-enterprise-init-appdb:10.2.25.6008-1", ContainerImage(util.InitAppdbImageUrlEnv, "10.2.25.6008-1", nil)) + + // env var has input already, but there is related image with this input + t.Setenv(fmt.Sprintf("RELATED_IMAGE_%s_12_0_4_7554_1", util.InitAppdbImageUrlEnv), "quay.io/mongodb/mongodb-enterprise-init-appdb@sha256:a48829ce36bf479dc25a4de79234c5621b67beee62ca98a099d0a56fdb04791c") + assert.Equal(t, "quay.io/mongodb/mongodb-enterprise-init-appdb@sha256:a48829ce36bf479dc25a4de79234c5621b67beee62ca98a099d0a56fdb04791c", ContainerImage(util.InitAppdbImageUrlEnv, "12.0.4.7554-1", nil)) + + t.Setenv(util.InitAppdbImageUrlEnv, "quay.io/mongodb/mongodb-enterprise-init-appdb@sha256:608daf56296c10c9bd02cc85bb542a849e9a66aff0697d6359b449540696b1fd") + // env var has input already as digest, but there is related image with this input + assert.Equal(t, "quay.io/mongodb/mongodb-enterprise-init-appdb@sha256:a48829ce36bf479dc25a4de79234c5621b67beee62ca98a099d0a56fdb04791c", ContainerImage(util.InitAppdbImageUrlEnv, "12.0.4.7554-1", nil)) + // env var has input already as digest, there is no related image with this input, so we use input instead of digest + assert.Equal(t, "quay.io/mongodb/mongodb-enterprise-init-appdb:1.2.3", ContainerImage(util.InitAppdbImageUrlEnv, "1.2.3", nil)) + + t.Setenv(util.OpsManagerImageUrl, "quay.io:3000/mongodb/ops-manager-kubernetes") + assert.Equal(t, "quay.io:3000/mongodb/ops-manager-kubernetes:1.2.3", ContainerImage(util.OpsManagerImageUrl, "1.2.3", nil)) + + t.Setenv(util.OpsManagerImageUrl, "localhost/mongodb/ops-manager-kubernetes") + assert.Equal(t, "localhost/mongodb/ops-manager-kubernetes:1.2.3", ContainerImage(util.OpsManagerImageUrl, "1.2.3", nil)) + + t.Setenv(util.OpsManagerImageUrl, "mongodb") + assert.Equal(t, "mongodb:1.2.3", ContainerImage(util.OpsManagerImageUrl, "1.2.3", nil)) +} + +func Test_DatabaseStatefulSetWithRelatedImages(t *testing.T) { + databaseRelatedImageEnv := fmt.Sprintf("RELATED_IMAGE_%s_1_0_0", util.AutomationAgentImage) + initDatabaseRelatedImageEnv := fmt.Sprintf("RELATED_IMAGE_%s_2_0_0", util.InitDatabaseImageUrlEnv) + + t.Setenv(util.AutomationAgentImage, "quay.io/mongodb/mongodb-enterprise-database") + t.Setenv(DatabaseVersionEnv, "1.0.0") + t.Setenv(util.InitDatabaseImageUrlEnv, "quay.io/mongodb/mongodb-enterprise-init-database") + t.Setenv(InitDatabaseVersionEnv, "2.0.0") + t.Setenv(databaseRelatedImageEnv, "quay.io/mongodb/mongodb-enterprise-database:@sha256:MONGODB_DATABASE") + t.Setenv(initDatabaseRelatedImageEnv, "quay.io/mongodb/mongodb-enterprise-init-database:@sha256:MONGODB_INIT_DATABASE") + + rs := mdbv1.NewReplicaSetBuilder().Build() + sts := DatabaseStatefulSet(*rs, ReplicaSetOptions(GetPodEnvOptions()), nil) + + assert.Equal(t, "quay.io/mongodb/mongodb-enterprise-init-database:@sha256:MONGODB_INIT_DATABASE", sts.Spec.Template.Spec.InitContainers[0].Image) + assert.Equal(t, "quay.io/mongodb/mongodb-enterprise-database:@sha256:MONGODB_DATABASE", sts.Spec.Template.Spec.Containers[0].Image) +} + +func TestGetAppDBImage(t *testing.T) { + // Note: if no construct.DefaultImageType is given, we will default to ubi8 + tests := []struct { + name string + input string + want string + setupEnvs func(t *testing.T) + }{ + { + name: "Getting official image", + input: "4.2.11-ubi8", + want: "quay.io/mongodb/mongodb-enterprise-server:4.2.11-ubi8", + setupEnvs: func(t *testing.T) { + t.Setenv(construct.MongodbRepoUrl, "quay.io/mongodb") + t.Setenv(construct.MongodbImageEnv, util.OfficialServerImageAppdbUrl) + }, + }, + { + name: "Getting official image without suffix", + input: "4.2.11", + want: "quay.io/mongodb/mongodb-enterprise-server:4.2.11-ubi8", + setupEnvs: func(t *testing.T) { + t.Setenv(construct.MongodbRepoUrl, "quay.io/mongodb") + t.Setenv(construct.MongodbImageEnv, util.OfficialServerImageAppdbUrl) + }, + }, + { + name: "Getting official image keep suffix", + input: "4.2.11-something", + want: "quay.io/mongodb/mongodb-enterprise-server:4.2.11-something", + setupEnvs: func(t *testing.T) { + t.Setenv(construct.MongodbRepoUrl, "quay.io/mongodb") + t.Setenv(construct.MongodbImageEnv, util.OfficialServerImageAppdbUrl) + }, + }, + { + name: "Getting official image with legacy suffix", + input: "4.2.11-ent", + want: "quay.io/mongodb/mongodb-enterprise-server:4.2.11-ubi8", + setupEnvs: func(t *testing.T) { + t.Setenv(construct.MongodbRepoUrl, "quay.io/mongodb") + t.Setenv(construct.MongodbImageEnv, util.OfficialServerImageAppdbUrl) + }, + }, + { + name: "Getting official image with legacy image", + input: "4.2.11-ent", + want: "quay.io/mongodb/mongodb-enterprise-appdb-database-ubi:4.2.11-ent", + setupEnvs: func(t *testing.T) { + t.Setenv(construct.MongodbRepoUrl, "quay.io/mongodb") + t.Setenv(construct.MongodbImageEnv, util.DeprecatedImageAppdbUbiUrl) + }, + }, + { + name: "Getting official image with related image from deprecated URL", + input: "4.2.11-ubi8", + want: "quay.io/mongodb/mongodb-enterprise-server:4.2.11-ubi8", + setupEnvs: func(t *testing.T) { + t.Setenv("RELATED_IMAGE_MONGODB_IMAGE_4_2_11_ubi8", "quay.io/mongodb/mongodb-enterprise-server:4.2.11-ubi8") + t.Setenv(construct.MongoDBImageType, "ubi8") + t.Setenv(construct.MongodbImageEnv, util.DeprecatedImageAppdbUbiUrl) + t.Setenv(construct.MongodbRepoUrl, construct.OfficialMongodbRepoUrls[1]) + }, + }, + { + name: "Getting official image with related image with ent suffix even if old related image exists", + input: "4.2.11-ent", + want: "quay.io/mongodb/mongodb-enterprise-server:4.2.11-ubi8", + setupEnvs: func(t *testing.T) { + t.Setenv("RELATED_IMAGE_MONGODB_IMAGE_4_2_11_ubi8", "quay.io/mongodb/mongodb-enterprise-server:4.2.11-ubi8") + t.Setenv("RELATED_IMAGE_MONGODB_IMAGE_4_2_11_ent", "quay.io/mongodb/mongodb-enterprise-server:4.2.11-ent") + t.Setenv(construct.MongoDBImageType, "ubi8") + t.Setenv(construct.MongodbImageEnv, util.OfficialServerImageAppdbUrl) + t.Setenv(construct.MongodbRepoUrl, construct.OfficialMongodbRepoUrls[1]) + }, + }, + { + name: "Getting deprecated image with related image from official URL. We do not replace -ent because the url is not a deprecated one we want to replace", + input: "4.2.11-ent", + want: "quay.io/mongodb/mongodb-enterprise-appdb-database-ubi:4.2.11-ent", + setupEnvs: func(t *testing.T) { + t.Setenv("RELATED_IMAGE_MONGODB_IMAGE_4_2_11_ubi8", "quay.io/mongodb/mongodb-enterprise-server:4.2.11-ubi8") + t.Setenv(construct.MongodbImageEnv, util.DeprecatedImageAppdbUbiUrl) + t.Setenv(construct.MongodbRepoUrl, construct.OfficialMongodbRepoUrls[1]) + }, + }, + { + name: "Getting official image with legacy suffix but stopping migration", + input: "4.2.11-ent", + want: "quay.io/mongodb/mongodb-enterprise-server:4.2.11-ent", + setupEnvs: func(t *testing.T) { + t.Setenv(construct.MongodbRepoUrl, "quay.io/mongodb") + t.Setenv(construct.MongodbImageEnv, util.OfficialServerImageAppdbUrl) + t.Setenv(util.MdbAppdbAssumeOldFormat, "true") + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.setupEnvs(t) + assert.Equalf(t, tt.want, getAppDBImage(tt.input), "getAppDBImage(%v)", tt.input) + }) + } +} diff --git a/controllers/operator/construct/database_volumes.go b/controllers/operator/construct/database_volumes.go new file mode 100644 index 000000000..99b5e9ede --- /dev/null +++ b/controllers/operator/construct/database_volumes.go @@ -0,0 +1,137 @@ +package construct + +import ( + "fmt" + "path" + + "go.uber.org/zap" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/certs" + "github.com/10gen/ops-manager-kubernetes/pkg/tls" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/vault" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/statefulset" + corev1 "k8s.io/api/core/v1" +) + +type MongoDBVolumeSource interface { + GetVolumes() []corev1.Volume + GetVolumeMounts() []corev1.VolumeMount + GetEnvs() []corev1.EnvVar + ShouldBeAdded() bool +} + +type caVolumeSource struct { + opts DatabaseStatefulSetOptions + logger *zap.SugaredLogger +} + +func (c *caVolumeSource) GetVolumes() []corev1.Volume { + return []corev1.Volume{statefulset.CreateVolumeFromConfigMap(CaCertName, c.opts.PodVars.SSLMMSCAConfigMap)} +} + +func (c *caVolumeSource) GetVolumeMounts() []corev1.VolumeMount { + return []corev1.VolumeMount{ + { + MountPath: caCertMountPath, + Name: CaCertName, + ReadOnly: true, + }, + } +} + +func (c *caVolumeSource) GetEnvs() []corev1.EnvVar { + // A custom CA has been provided, point the trusted CA to the location of custom CAs + trustedCACertLocation := path.Join(caCertMountPath, util.CaCertMMS) + return []corev1.EnvVar{ + { + Name: util.EnvVarSSLTrustedMMSServerCertificate, + Value: trustedCACertLocation, + }, + } +} + +func (c *caVolumeSource) ShouldBeAdded() bool { + return c.opts.PodVars != nil && c.opts.PodVars.SSLMMSCAConfigMap != "" +} + +// tlsVolumeSource provides the volume and volumeMounts that need to be created for TLS. +type tlsVolumeSource struct { + security *mdbv1.Security + databaseOpts DatabaseStatefulSetOptions + logger *zap.SugaredLogger +} + +func (c *tlsVolumeSource) getVolumesAndMounts() ([]corev1.Volume, []corev1.VolumeMount) { + var volumes []corev1.Volume + var volumeMounts []corev1.VolumeMount + + security := c.security + databaseOpts := c.databaseOpts + + // We default each value to the "old-design" + tlsConfig := security.TLSConfig + if !security.IsTLSEnabled() { + return volumes, volumeMounts + } + + secretName := security.MemberCertificateSecretName(databaseOpts.Name) + secretMountPath := util.SecretVolumeMountPath + "/certs" + configmapMountPath := util.ConfigMapVolumeCAMountPath + + volumeSecretName := secretName + + caName := fmt.Sprintf("%s-ca", databaseOpts.Name) + if tlsConfig != nil && tlsConfig.CA != "" { + caName = tlsConfig.CA + } else { + c.logger.Debugf("No CA name has been supplied, defaulting to: %s", caName) + } + + // This two functions modify the volumes to be optional (the absence of the referenced + // secret/configMap do not prevent the pods from starting) + optionalSecretFunc := func(v *corev1.Volume) { v.Secret.Optional = util.BooleanRef(true) } + optionalConfigMapFunc := func(v *corev1.Volume) { v.ConfigMap.Optional = util.BooleanRef(true) } + + secretMountPath = util.TLSCertMountPath + configmapMountPath = util.TLSCaMountPath + volumeSecretName = fmt.Sprintf("%s%s", secretName, certs.OperatorGeneratedCertSuffix) + + if !vault.IsVaultSecretBackend() { + secretVolume := statefulset.CreateVolumeFromSecret(util.SecretVolumeName, volumeSecretName, optionalSecretFunc) + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + MountPath: secretMountPath, + Name: secretVolume.Name, + ReadOnly: true, + }) + volumes = append(volumes, secretVolume) + } + + caVolume := statefulset.CreateVolumeFromConfigMap(tls.ConfigMapVolumeCAName, caName, optionalConfigMapFunc) + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + MountPath: configmapMountPath, + Name: caVolume.Name, + ReadOnly: true, + }) + volumes = append(volumes, caVolume) + return volumes, volumeMounts +} + +func (c *tlsVolumeSource) GetVolumes() []corev1.Volume { + volumes, _ := c.getVolumesAndMounts() + return volumes + +} +func (c *tlsVolumeSource) GetVolumeMounts() []corev1.VolumeMount { + _, volumeMounts := c.getVolumesAndMounts() + return volumeMounts + +} +func (c *tlsVolumeSource) GetEnvs() []corev1.EnvVar { + return []corev1.EnvVar{} +} + +func (c *tlsVolumeSource) ShouldBeAdded() bool { + return c.security.IsTLSEnabled() +} diff --git a/controllers/operator/construct/jvm.go b/controllers/operator/construct/jvm.go new file mode 100644 index 000000000..b5e75db43 --- /dev/null +++ b/controllers/operator/construct/jvm.go @@ -0,0 +1,117 @@ +package construct + +import ( + "fmt" + "strings" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/container" + "golang.org/x/xerrors" + + omv1 "github.com/10gen/ops-manager-kubernetes/api/v1/om" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" +) + +const ( + opsManagerPodMemPercentage = 90 + oneMB = 1048576 + backupDaemonHealthPort = 8090 +) + +// setJvmArgsEnvVars sets the correct environment variables for JVM size parameters. +// This method must be invoked on the final version of the StatefulSet (after user statefulSet spec +// was merged) +func setJvmArgsEnvVars(om omv1.MongoDBOpsManagerSpec, containerName string, sts *appsv1.StatefulSet) error { + jvmParamsEnvVars, err := buildJvmParamsEnvVars(om, containerName, sts.Spec.Template) + if err != nil { + return err + } + // pass Xmx java parameter to container (note, that we don't need to sort the env variables again + // as the jvm params order is consistent) + for _, envVar := range jvmParamsEnvVars { + omContainer := container.GetByName(containerName, sts.Spec.Template.Spec.Containers) + omContainer.Env = append(omContainer.Env, envVar) + } + return nil +} + +// buildJvmParamsEnvVars returns a slice of corev1.EnvVars that should be added to the Backup Daemon +// or Ops Manager containers. +func buildJvmParamsEnvVars(m omv1.MongoDBOpsManagerSpec, containerName string, template corev1.PodTemplateSpec) ([]corev1.EnvVar, error) { + mmsJvmEnvVar := corev1.EnvVar{Name: util.MmsJvmParamEnvVar} + backupJvmEnvVar := corev1.EnvVar{Name: util.BackupDaemonJvmParamEnvVar} + + omContainer := container.GetByName(containerName, template.Spec.Containers) + // calculate xmx from container's memory limit + memLimits := omContainer.Resources.Limits.Memory() + maxPodMem, err := getPercentOfQuantityAsInt(*memLimits, opsManagerPodMemPercentage) + if err != nil { + return []corev1.EnvVar{}, xerrors.Errorf("error calculating xmx from pod mem: %w", err) + } + + // calculate xms from container's memory request if it is set, otherwise xms=xmx + memRequests := omContainer.Resources.Requests.Memory() + minPodMem, err := getPercentOfQuantityAsInt(*memRequests, opsManagerPodMemPercentage) + if err != nil { + return []corev1.EnvVar{}, xerrors.Errorf("error calculating xms from pod mem: %w", err) + } + + // if only one of mem limits/requests is set, use that value for both xmx & xms + if minPodMem == 0 { + minPodMem = maxPodMem + } + if maxPodMem == 0 { + maxPodMem = minPodMem + } + + memParams := fmt.Sprintf("-Xmx%dm -Xms%dm", maxPodMem, minPodMem) + mmsJvmEnvVar.Value = buildJvmEnvVar(m.JVMParams, memParams) + backupJvmEnvVar.Value = buildJvmEnvVar(m.Backup.JVMParams, memParams) + + // Debug port reports the status of the AppDB, this can be used to determine if the Backup Daemon is in a good state. + // this exposes a /health endpoint which returns {"sync_db":"OK","backup_db":"OK","mms_db":"OK"} + // https://github.com/10gen/mms/blob/8c4047d67e157672051d37e340305d89ad20964a/server/src/main/com/xgen/svc/brs/grid/Daemon.java#L926 + backupJvmEnvVar.Value += fmt.Sprintf(" -DDAEMON.DEBUG.PORT=%d", backupDaemonHealthPort) + return []corev1.EnvVar{mmsJvmEnvVar, backupJvmEnvVar}, nil +} + +// getPercentOfQuantityAsInt returns the percentage of a given quantity as an int. +func getPercentOfQuantityAsInt(q resource.Quantity, percent int) (int, error) { + quantityAsInt, canConvert := q.AsInt64() + if !canConvert { + // the container's mem can't be converted to int64, use default of 5G + podMem, err := resource.ParseQuantity(util.DefaultMemoryOpsManager) + if err != nil { + return 0, err + } + quantityAsInt, canConvert = podMem.AsInt64() + if !canConvert { + return 0, xerrors.Errorf("cannot convert %s to int64", podMem.String()) + } + } + percentage := float64(percent) / 100.0 + + return int(float64(quantityAsInt)*percentage) / oneMB, nil +} + +// buildJvmEnvVar returns the string representation of the JVM environment variable +func buildJvmEnvVar(customParams []string, containerMemParams string) string { + jvmParams := strings.Join(customParams, " ") + + // if both mem limits and mem requests are unset/have value 0, we don't want to override om's default JVM xmx/xms params + if strings.Contains(containerMemParams, "-Xmx0m") { + return jvmParams + } + + if strings.Contains(jvmParams, "Xmx") { + return jvmParams + } + + if jvmParams != "" { + jvmParams += " " + } + + return jvmParams + containerMemParams +} diff --git a/controllers/operator/construct/multicluster/multicluster_replicaset.go b/controllers/operator/construct/multicluster/multicluster_replicaset.go new file mode 100644 index 000000000..5a90c6a03 --- /dev/null +++ b/controllers/operator/construct/multicluster/multicluster_replicaset.go @@ -0,0 +1,108 @@ +package multicluster + +import ( + "fmt" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + mdbmultiv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdbmulti" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/certs" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/construct" + "github.com/10gen/ops-manager-kubernetes/pkg/handler" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/util/merge" + appsv1 "k8s.io/api/apps/v1" +) + +func MultiClusterReplicaSetOptions(additionalOpts ...func(options *construct.DatabaseStatefulSetOptions)) func(mdbm mdbmultiv1.MongoDBMultiCluster) construct.DatabaseStatefulSetOptions { + return func(mdbm mdbmultiv1.MongoDBMultiCluster) construct.DatabaseStatefulSetOptions { + stsSpec := appsv1.StatefulSetSpec{} + if mdbm.Spec.StatefulSetConfiguration != nil { + stsSpec = mdbm.Spec.StatefulSetConfiguration.SpecWrapper.Spec + } + opts := construct.DatabaseStatefulSetOptions{ + Name: mdbm.Name, + ServicePort: mdbm.Spec.GetAdditionalMongodConfig().GetPortOrDefault(), + Persistent: mdbm.Spec.Persistent, + AgentConfig: mdbm.Spec.Agent, + PodSpec: construct.NewDefaultPodSpecWrapper(*mdbv1.NewMongoDbPodSpec()), + Labels: statefulSetLabels(mdbm.Name, mdbm.Namespace), + MultiClusterMode: "true", + HostNameOverrideConfigmapName: mdbm.GetHostNameOverrideConfigmapName(), + StatefulSetSpecOverride: &stsSpec, + StsType: construct.MultiReplicaSet, + } + for _, opt := range additionalOpts { + opt(&opts) + } + + return opts + } +} + +func WithClusterNum(clusterNum int) func(options *construct.DatabaseStatefulSetOptions) { + return func(options *construct.DatabaseStatefulSetOptions) { + options.StatefulSetNameOverride = statefulSetName(options.Name, clusterNum) + } +} + +func WithMemberCount(memberCount int) func(options *construct.DatabaseStatefulSetOptions) { + return func(options *construct.DatabaseStatefulSetOptions) { + options.Replicas = memberCount + } +} + +func WithStsOverride(stsOverride *appsv1.StatefulSetSpec) func(options *construct.DatabaseStatefulSetOptions) { + return func(options *construct.DatabaseStatefulSetOptions) { + finalSpec := merge.StatefulSetSpecs(*options.StatefulSetSpecOverride, *stsOverride) + options.StatefulSetSpecOverride = &finalSpec + } +} + +func WithAnnotations(resourceName string, certHash string) func(options *construct.DatabaseStatefulSetOptions) { + return func(options *construct.DatabaseStatefulSetOptions) { + options.Annotations = statefulSetAnnotations(resourceName, certHash) + } +} + +func statefulSetName(mdbmName string, clusterNum int) string { + return fmt.Sprintf("%s-%d", mdbmName, clusterNum) +} + +func statefulSetLabels(mdbmName, mdbmNamespace string) map[string]string { + return map[string]string{ + "controller": "mongodb-enterprise-operator", + "mongodbmulticluster": fmt.Sprintf("%s-%s", mdbmName, mdbmNamespace), + } +} + +func statefulSetAnnotations(mdbmName string, certHash string) map[string]string { + return map[string]string{ + handler.MongoDBMultiResourceAnnotation: mdbmName, + certs.CertHashAnnotationKey: certHash, + } +} + +func PodLabel(mdbmName string) map[string]string { + return map[string]string{ + construct.ControllerLabelName: "mongodb-enterprise-operator", + construct.PodAntiAffinityLabelKey: mdbmName, + } +} + +func MultiClusterStatefulSet(mdbm mdbmultiv1.MongoDBMultiCluster, stsOptFunc func(mdbm mdbmultiv1.MongoDBMultiCluster) construct.DatabaseStatefulSetOptions) appsv1.StatefulSet { + stsOptions := stsOptFunc(mdbm) + dbSts := construct.DatabaseStatefulSetHelper(&mdbm, &stsOptions, nil) + + if len(stsOptions.Annotations) > 0 { + dbSts.Annotations = merge.StringToStringMap(dbSts.Annotations, stsOptions.Annotations) + } + + if len(stsOptions.Labels) > 0 { + dbSts.Labels = merge.StringToStringMap(dbSts.Labels, stsOptions.Labels) + } + + if stsOptions.StatefulSetSpecOverride != nil { + dbSts.Spec = merge.StatefulSetSpecs(dbSts.Spec, *stsOptions.StatefulSetSpecOverride) + } + + return dbSts +} diff --git a/controllers/operator/construct/multicluster/multicluster_replicaset_test.go b/controllers/operator/construct/multicluster/multicluster_replicaset_test.go new file mode 100644 index 000000000..24d9d36e4 --- /dev/null +++ b/controllers/operator/construct/multicluster/multicluster_replicaset_test.go @@ -0,0 +1,270 @@ +package multicluster + +import ( + "fmt" + "os" + "testing" + + mdbc "github.com/mongodb/mongodb-kubernetes-operator/api/v1" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/api/v1/mdbmulti" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/construct" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/mock" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/stretchr/testify/assert" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/pointer" +) + +func init() { + mock.InitDefaultEnvVariables() +} + +func getMultiClusterMongoDB() mdbmulti.MongoDBMultiCluster { + spec := mdbmulti.MongoDBMultiSpec{ + DbCommonSpec: mdbv1.DbCommonSpec{ + Version: "5.0.0", + ConnectionSpec: mdbv1.ConnectionSpec{ + SharedConnectionSpec: mdbv1.SharedConnectionSpec{ + OpsManagerConfig: &mdbv1.PrivateCloudConfig{ + ConfigMapRef: mdbv1.ConfigMapRef{ + Name: mock.TestProjectConfigMapName, + }, + }, + }, Credentials: mock.TestCredentialsSecretName, + }, + ResourceType: mdbv1.ReplicaSet, + Security: &mdbv1.Security{ + TLSConfig: &mdbv1.TLSConfig{}, + Authentication: &mdbv1.Authentication{ + Modes: []string{}, + }, + Roles: []mdbv1.MongoDbRole{}, + }, + }, + ClusterSpecList: []mdbmulti.ClusterSpecItem{ + { + ClusterName: "foo", + Members: 3, + }, + }, + } + + return mdbmulti.MongoDBMultiCluster{Spec: spec, ObjectMeta: metav1.ObjectMeta{Name: "pod-aff", Namespace: mock.TestNamespace}} +} + +func TestMultiClusterStatefulSet(t *testing.T) { + + t.Run("No override provided", func(t *testing.T) { + mdbm := getMultiClusterMongoDB() + opts := MultiClusterReplicaSetOptions( + WithClusterNum(0), + WithMemberCount(3), + construct.GetPodEnvOptions(), + ) + sts := MultiClusterStatefulSet(mdbm, opts) + + expectedReplicas := mdbm.Spec.ClusterSpecList[0].Members + assert.Equal(t, expectedReplicas, int(*sts.Spec.Replicas)) + + }) + + t.Run("Override provided at clusterSpecList level only", func(t *testing.T) { + singleClusterOverride := &mdbc.StatefulSetConfiguration{SpecWrapper: mdbc.StatefulSetSpecWrapper{ + Spec: appsv1.StatefulSetSpec{ + Replicas: pointer.Int32(int32(4)), + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"foo": "bar"}, + }, + }, + }} + + mdbm := getMultiClusterMongoDB() + mdbm.Spec.ClusterSpecList[0].StatefulSetConfiguration = singleClusterOverride + + opts := MultiClusterReplicaSetOptions( + WithClusterNum(0), + WithMemberCount(3), + construct.GetPodEnvOptions(), + WithStsOverride(&singleClusterOverride.SpecWrapper.Spec), + ) + + sts := MultiClusterStatefulSet(mdbm, opts) + + expectedMatchLabels := singleClusterOverride.SpecWrapper.Spec.Selector.MatchLabels + expectedMatchLabels["app"] = "" + expectedMatchLabels["pod-anti-affinity"] = mdbm.Name + expectedMatchLabels["controller"] = "mongodb-enterprise-operator" + + assert.Equal(t, singleClusterOverride.SpecWrapper.Spec.Replicas, sts.Spec.Replicas) + assert.Equal(t, expectedMatchLabels, sts.Spec.Selector.MatchLabels) + + }) + + t.Run("Override provided only at Spec level", func(t *testing.T) { + stsOverride := &mdbc.StatefulSetConfiguration{SpecWrapper: mdbc.StatefulSetSpecWrapper{Spec: appsv1.StatefulSetSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"foo": "bar"}, + }, + ServiceName: "overrideservice", + }, + }, + } + + mdbm := getMultiClusterMongoDB() + mdbm.Spec.StatefulSetConfiguration = stsOverride + opts := MultiClusterReplicaSetOptions( + WithClusterNum(0), + WithMemberCount(3), + construct.GetPodEnvOptions(), + ) + + sts := MultiClusterStatefulSet(mdbm, opts) + + expectedReplicas := mdbm.Spec.ClusterSpecList[0].Members + assert.Equal(t, expectedReplicas, int(*sts.Spec.Replicas)) + + assert.Equal(t, stsOverride.SpecWrapper.Spec.ServiceName, sts.Spec.ServiceName) + + }) + + t.Run("Override provided at both Spec and clusterSpecList level", func(t *testing.T) { + + stsOverride := &mdbc.StatefulSetConfiguration{SpecWrapper: mdbc.StatefulSetSpecWrapper{Spec: appsv1.StatefulSetSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"foo": "bar"}, + }, + ServiceName: "overrideservice", + }, + }, + } + + singleClusterOverride := &mdbc.StatefulSetConfiguration{SpecWrapper: mdbc.StatefulSetSpecWrapper{ + Spec: appsv1.StatefulSetSpec{ + ServiceName: "clusteroverrideservice", + Replicas: pointer.Int32(int32(4)), + }, + }, + } + + mdbm := getMultiClusterMongoDB() + mdbm.Spec.StatefulSetConfiguration = stsOverride + + opts := MultiClusterReplicaSetOptions( + WithClusterNum(0), + WithMemberCount(3), + construct.GetPodEnvOptions(), + WithStsOverride(&singleClusterOverride.SpecWrapper.Spec), + ) + + sts := MultiClusterStatefulSet(mdbm, opts) + + assert.Equal(t, singleClusterOverride.SpecWrapper.Spec.ServiceName, sts.Spec.ServiceName) + assert.Equal(t, singleClusterOverride.SpecWrapper.Spec.Replicas, sts.Spec.Replicas) + }) +} + +func Test_MultiClusterStatefulSetWithRelatedImages(t *testing.T) { + databaseRelatedImageEnv := fmt.Sprintf("RELATED_IMAGE_%s_1_0_0", util.AutomationAgentImage) + initDatabaseRelatedImageEnv := fmt.Sprintf("RELATED_IMAGE_%s_2_0_0", util.InitDatabaseImageUrlEnv) + + t.Setenv(util.AutomationAgentImage, "quay.io/mongodb/mongodb-enterprise-database") + t.Setenv(construct.DatabaseVersionEnv, "1.0.0") + t.Setenv(util.InitDatabaseImageUrlEnv, "quay.io/mongodb/mongodb-enterprise-init-database") + t.Setenv(construct.InitDatabaseVersionEnv, "2.0.0") + t.Setenv(databaseRelatedImageEnv, "quay.io/mongodb/mongodb-enterprise-database:@sha256:MONGODB_DATABASE") + t.Setenv(initDatabaseRelatedImageEnv, "quay.io/mongodb/mongodb-enterprise-init-database:@sha256:MONGODB_INIT_DATABASE") + + mdbm := getMultiClusterMongoDB() + opts := MultiClusterReplicaSetOptions( + WithClusterNum(0), + WithMemberCount(3), + construct.GetPodEnvOptions(), + ) + + sts := MultiClusterStatefulSet(mdbm, opts) + + assert.Equal(t, "quay.io/mongodb/mongodb-enterprise-init-database:@sha256:MONGODB_INIT_DATABASE", sts.Spec.Template.Spec.InitContainers[0].Image) + assert.Equal(t, "quay.io/mongodb/mongodb-enterprise-database:@sha256:MONGODB_DATABASE", sts.Spec.Template.Spec.Containers[0].Image) +} + +func TestPVCOverride(t *testing.T) { + + tests := []struct { + inp appsv1.StatefulSetSpec + out struct { + Storage int64 + AccessMode []corev1.PersistentVolumeAccessMode + } + }{ + { + inp: appsv1.StatefulSetSpec{ + VolumeClaimTemplates: []corev1.PersistentVolumeClaim{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "data", + }, + Spec: corev1.PersistentVolumeClaimSpec{ + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: construct.ParseQuantityOrZero("20"), + }, + }, + AccessModes: []corev1.PersistentVolumeAccessMode{}, + }, + }, + }, + }, + out: struct { + Storage int64 + AccessMode []corev1.PersistentVolumeAccessMode + }{ + Storage: 20, + AccessMode: []corev1.PersistentVolumeAccessMode{"ReadWriteOnce"}, + }, + }, + { + inp: appsv1.StatefulSetSpec{ + VolumeClaimTemplates: []corev1.PersistentVolumeClaim{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "data", + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{"ReadWriteMany"}, + }, + }, + }, + }, + out: struct { + Storage int64 + AccessMode []corev1.PersistentVolumeAccessMode + }{ + Storage: 16000000000, + AccessMode: []corev1.PersistentVolumeAccessMode{"ReadWriteOnce", "ReadWriteMany"}, + }, + }, + } + + os.Setenv(util.AutomationAgentImage, "some-registry") + os.Setenv(util.InitDatabaseImageUrlEnv, "some-registry") + + for _, tt := range tests { + mdbm := getMultiClusterMongoDB() + + stsOverrideConfiguration := &mdbc.StatefulSetConfiguration{SpecWrapper: mdbc.StatefulSetSpecWrapper{Spec: tt.inp}} + opts := MultiClusterReplicaSetOptions( + WithClusterNum(0), + WithMemberCount(3), + construct.GetPodEnvOptions(), + WithStsOverride(&stsOverrideConfiguration.SpecWrapper.Spec), + ) + sts := MultiClusterStatefulSet(mdbm, opts) + assert.Equal(t, tt.out.AccessMode, sts.Spec.VolumeClaimTemplates[0].Spec.AccessModes) + storage, _ := sts.Spec.VolumeClaimTemplates[0].Spec.Resources.Requests.Storage().AsInt64() + assert.Equal(t, tt.out.Storage, storage) + } +} diff --git a/controllers/operator/construct/opsmanager_construction.go b/controllers/operator/construct/opsmanager_construction.go new file mode 100644 index 000000000..25440f3e4 --- /dev/null +++ b/controllers/operator/construct/opsmanager_construction.go @@ -0,0 +1,663 @@ +package construct + +import ( + "context" + "fmt" + "net" + + v1 "github.com/10gen/ops-manager-kubernetes/api/v1" + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + omv1 "github.com/10gen/ops-manager-kubernetes/api/v1/om" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/certs" + enterprisepem "github.com/10gen/ops-manager-kubernetes/controllers/operator/pem" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/secrets" + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" + "github.com/10gen/ops-manager-kubernetes/pkg/vault" + kubernetesClient "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/client" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/container" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/lifecycle" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/podtemplatespec" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/probes" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/secret" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/statefulset" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/util/merge" + "go.uber.org/zap" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +const ( + appLabelKey = "app" + podAntiAffinityLabelKey = "pod-anti-affinity" +) + +func GetOpsManagerCAFileDir() string { + return fmt.Sprintf("%s/certs/%s", MMSHome, "ops-manager-ca") +} + +// OpsManagerStatefulSetOptions contains all of the different values that are variable between +// StatefulSets. Depending on which StatefulSet is being built, a number of these will be pre-set, +// while the remainder will be configurable via configuration functions which modify this type. +type OpsManagerStatefulSetOptions struct { + OwnerReference []metav1.OwnerReference + HTTPSCertSecretName string + CertHash string + AppDBTlsCAConfigMapName string + AppDBConnectionSecretName string + AppDBConnectionStringHash string + EnvVars []corev1.EnvVar + Version string + Name string + Replicas int + ServiceName string + Namespace string + OwnerName string + ServicePort int + OpsManagerCaName string + QueryableBackupPemSecretName string + StatefulSetSpecOverride *appsv1.StatefulSetSpec + VaultConfig vault.VaultConfiguration + Labels map[string]string + kmip *KmipConfiguration + // backup daemon only + HeadDbPersistenceConfig *mdbv1.PersistenceConfig +} + +type KmipClientConfiguration struct { + ClientCertificateSecretName string + ClientCertificatePasswordSecretName *string + ClientCertificatePasswordSecretKeyName *string +} + +type KmipConfiguration struct { + ServerConfiguration v1.KmipServerConfig + ClientConfigurations []KmipClientConfiguration +} + +func WithConnectionStringHash(hash string) func(opts *OpsManagerStatefulSetOptions) { + return func(opts *OpsManagerStatefulSetOptions) { + opts.AppDBConnectionStringHash = hash + } +} + +func WithVaultConfig(config vault.VaultConfiguration) func(opts *OpsManagerStatefulSetOptions) { + return func(opts *OpsManagerStatefulSetOptions) { + opts.VaultConfig = config + } +} + +func WithKmipConfig(opsManager omv1.MongoDBOpsManager, client kubernetesClient.Client, log *zap.SugaredLogger) func(opts *OpsManagerStatefulSetOptions) { + return func(opts *OpsManagerStatefulSetOptions) { + if !opsManager.Spec.IsKmipEnabled() { + return + } + opts.kmip = &KmipConfiguration{ + ServerConfiguration: opsManager.Spec.Backup.Encryption.Kmip.Server, + ClientConfigurations: make([]KmipClientConfiguration, 0), + } + + mdbList := &mdbv1.MongoDBList{} + err := client.List(context.TODO(), mdbList) + if err != nil { + log.Warnf("failed to fetch MongoDBList from Kubernetes: %v", err) + } + + for _, m := range mdbList.Items { + // Since KMIP integration requires the secret to be mounted into the backup daemon + // we might not be able to do any encrypted backups across namespaces. Such a backup + // would require syncing secrets across namespaces. + // I'm not adding any namespace validation, and we'll let the user handle such synchronization as + // the backup Daemon will hang in Pending until the secret is provided. + if m.Spec.Backup != nil && m.Spec.Backup.IsKmipEnabled() { + c := m.Spec.Backup.Encryption.Kmip.Client + config := KmipClientConfiguration{ + ClientCertificateSecretName: c.ClientCertificateSecretName(m.GetName()), + } + + clientCertificatePasswordSecret := &corev1.Secret{} + err := client.Get(context.TODO(), kube.ObjectKey(m.GetNamespace(), c.ClientCertificatePasswordSecretName(m.GetName())), clientCertificatePasswordSecret) + if !apiErrors.IsNotFound(err) { + log.Warnf("failed to fetch the %s Secret from Kubernetes: %v", c.ClientCertificateSecretName(m.GetName()), err) + } else if err == nil { + clientCertificateSecretName := c.ClientCertificatePasswordSecretName(m.GetName()) + clientCertificatePasswordKey := c.ClientCertificatePasswordKeyName() + config.ClientCertificatePasswordSecretKeyName = &clientCertificateSecretName + config.ClientCertificatePasswordSecretName = &clientCertificatePasswordKey + } + + opts.kmip.ClientConfigurations = append(opts.kmip.ClientConfigurations, config) + } + } + } +} + +// updateHTTPSCertSecret updates the fields for the OpsManager HTTPS certificate in case the provided secret is of type kubernetes.io/tls. +func (opts *OpsManagerStatefulSetOptions) updateHTTPSCertSecret(secretGetterCreattor secrets.SecretClient, ownerReferences []metav1.OwnerReference, log *zap.SugaredLogger) error { + // Return immediately if no Certificate is provided + if opts.HTTPSCertSecretName == "" { + return nil + } + + var err error + var secretData map[string][]byte + var s corev1.Secret + var opsManagerSecretPath string + + if vault.IsVaultSecretBackend() { + opsManagerSecretPath = secretGetterCreattor.VaultClient.OpsManagerSecretPath() + secretData, err = secretGetterCreattor.VaultClient.ReadSecretBytes(fmt.Sprintf("%s/%s/%s", opsManagerSecretPath, opts.Namespace, opts.HTTPSCertSecretName)) + if err != nil { + return err + } + } else { + s, err = secretGetterCreattor.KubeClient.GetSecret(kube.ObjectKey(opts.Namespace, opts.HTTPSCertSecretName)) + if err != nil { + return err + } + + // SecretTypeTLS is kubernetes.io/tls + // This is the standard way in K8S to have secrets that hold TLS certs + // And it is the one generated by cert manager + // These type of secrets contain tls.crt and tls.key entries + if s.Type != corev1.SecretTypeTLS { + return nil + } + secretData = s.Data + } + + data, err := certs.VerifyTLSSecretForStatefulSet(secretData, certs.Options{}) + if err != nil { + return err + } + + certHash := enterprisepem.ReadHashFromSecret(secretGetterCreattor, opts.Namespace, opts.HTTPSCertSecretName, opsManagerSecretPath, log) + + // The operator concatenates the two fields of the secret into a PEM secret + err = certs.CreatePEMSecretClient(secretGetterCreattor, kube.ObjectKey(opts.Namespace, opts.HTTPSCertSecretName), map[string]string{certHash: data}, ownerReferences, certs.OpsManager) + if err != nil { + return err + } + + opts.HTTPSCertSecretName = fmt.Sprintf("%s%s", opts.HTTPSCertSecretName, certs.OperatorGeneratedCertSuffix) + opts.CertHash = certHash + + return nil +} + +// OpsManagerStatefulSet is the base method for building StatefulSet shared by Ops Manager and Backup Daemon. +// Shouldn't be called by end users directly +func OpsManagerStatefulSet(secretGetterCreator secrets.SecretClient, opsManager omv1.MongoDBOpsManager, log *zap.SugaredLogger, additionalOpts ...func(*OpsManagerStatefulSetOptions)) (appsv1.StatefulSet, error) { + opts := opsManagerOptions(additionalOpts...)(opsManager) + + if err := opts.updateHTTPSCertSecret(secretGetterCreator, opsManager.OwnerReferences, log); err != nil { + return appsv1.StatefulSet{}, err + } + + secretName := opsManager.Spec.Backup.QueryableBackupSecretRef.Name + opts.QueryableBackupPemSecretName = secretName + if secretName != "" { + // if the secret is specified, we must have a queryable.pem entry. + _, err := secret.ReadKey(secretGetterCreator, "queryable.pem", kube.ObjectKey(opsManager.Namespace, secretName)) + if err != nil { + return appsv1.StatefulSet{}, err + } + } + + omSts := statefulset.New(opsManagerStatefulSetFunc(opts)) + var err error + if opts.StatefulSetSpecOverride != nil { + omSts.Spec = merge.StatefulSetSpecs(omSts.Spec, *opts.StatefulSetSpecOverride) + } + + // the JVM env args must be determined after any potential stateful set override + // has taken place. + if err = setJvmArgsEnvVars(opsManager.Spec, util.OpsManagerContainerName, &omSts); err != nil { + return appsv1.StatefulSet{}, err + } + return omSts, nil + +} + +// getSharedOpsManagerOptions returns the options that are shared between both the OpsManager +// and BackupDaemon StatefulSets +func getSharedOpsManagerOptions(opsManager omv1.MongoDBOpsManager) OpsManagerStatefulSetOptions { + return OpsManagerStatefulSetOptions{ + OwnerReference: kube.BaseOwnerReference(&opsManager), + OwnerName: opsManager.Name, + HTTPSCertSecretName: opsManager.TLSCertificateSecretName(), + AppDBTlsCAConfigMapName: opsManager.Spec.AppDB.GetCAConfigMapName(), + EnvVars: opsManagerConfigurationToEnvVars(opsManager), + Version: opsManager.Spec.Version, + Namespace: opsManager.Namespace, + OpsManagerCaName: opsManager.Spec.GetOpsManagerCA(), + Labels: opsManager.Labels, + } +} + +// opsManagerOptions returns a function which returns the OpsManagerStatefulSetOptions to create the OpsManager StatefulSet +func opsManagerOptions(additionalOpts ...func(opts *OpsManagerStatefulSetOptions)) func(opsManager omv1.MongoDBOpsManager) OpsManagerStatefulSetOptions { + return func(opsManager omv1.MongoDBOpsManager) OpsManagerStatefulSetOptions { + var stsSpec *appsv1.StatefulSetSpec = nil + if opsManager.Spec.StatefulSetConfiguration != nil { + stsSpec = &opsManager.Spec.StatefulSetConfiguration.SpecWrapper.Spec + } + + _, port := opsManager.GetSchemePort() + + opts := getSharedOpsManagerOptions(opsManager) + opts.ServicePort = port + opts.ServiceName = opsManager.SvcName() + opts.Replicas = opsManager.Spec.Replicas + opts.Name = opsManager.Name + opts.StatefulSetSpecOverride = stsSpec + opts.AppDBConnectionSecretName = opsManager.AppDBMongoConnectionStringSecretName() + + for _, additionalOpt := range additionalOpts { + additionalOpt(&opts) + } + return opts + } +} + +// opsManagerStatefulSetFunc constructs the default Ops Manager StatefulSet modification function. +func opsManagerStatefulSetFunc(opts OpsManagerStatefulSetOptions) statefulset.Modification { + postStart := func(lc *corev1.Lifecycle) {} + if opts.AppDBTlsCAConfigMapName != "" { + // It will add each X.509 public key certificate into JVM's trust store + // with unique "mongodb_operator_added_trust_ca_$RANDOM" alias + // See: https://jira.mongodb.org/browse/HELP-25872 for more details. + + postStart = func(lc *corev1.Lifecycle) { + if lc.PostStart == nil { + lc.PostStart = &corev1.LifecycleHandler{Exec: &corev1.ExecAction{}} + } + lc.PostStart.Exec.Command = []string{"/bin/sh", "-c", postStartScriptCmd()} + } + } + + _, configureContainerSecurityContext := podtemplatespec.WithDefaultSecurityContextsModifications() + + return statefulset.Apply( + backupAndOpsManagerSharedConfiguration(opts), + statefulset.WithPodSpecTemplate( + podtemplatespec.Apply( + // 5 minutes for Ops Manager just in case (its internal timeout is 20 seconds anyway) + podtemplatespec.WithTerminationGracePeriodSeconds(300), + podtemplatespec.WithContainerByIndex(0, + container.Apply( + configureContainerSecurityContext, + container.WithLifecycle(postStart), + container.WithCommand([]string{"/opt/scripts/docker-entry-point.sh"}), + container.WithName(util.OpsManagerContainerName), + container.WithLivenessProbe(opsManagerLivenessProbe()), + container.WithStartupProbe(opsManagerStartupProbe()), + container.WithReadinessProbe(opsManagerReadinessProbe()), + container.WithLifecycle(buildOpsManagerLifecycle()), + container.WithEnvs(corev1.EnvVar{Name: "ENABLE_IRP", Value: "true"}), + ), + ), + )), + ) +} + +// backupAndOpsManagerSharedConfiguration returns a function which configures all of the shared +// options between the backup and Ops Manager StatefulSet +func backupAndOpsManagerSharedConfiguration(opts OpsManagerStatefulSetOptions) statefulset.Modification { + omImageURL := ContainerImage(util.OpsManagerImageUrl, opts.Version, nil) + + configurePodSpecSecurityContext, configureContainerSecurityContext := podtemplatespec.WithDefaultSecurityContextsModifications() + + pullSecretsConfigurationFunc := podtemplatespec.NOOP() + if pullSecrets, ok := env.Read(util.ImagePullSecrets); ok { + pullSecretsConfigurationFunc = podtemplatespec.WithImagePullSecrets(pullSecrets) + } + var omVolumeMounts []corev1.VolumeMount + + omScriptsVolume := statefulset.CreateVolumeFromEmptyDir("ops-manager-scripts") + omVolumes := []corev1.Volume{omScriptsVolume} + + omScriptsVolumeMount := buildOmScriptsVolumeMount(true) + omVolumeMounts = append(omVolumeMounts, omScriptsVolumeMount) + + vaultSecrets := vault.OpsManagerSecretsToInject{Config: opts.VaultConfig} + if vault.IsVaultSecretBackend() { + vaultSecrets.GenKeyPath = fmt.Sprintf("%s-gen-key", opts.OwnerName) + } else { + genKeyVolume := statefulset.CreateVolumeFromSecret("gen-key", fmt.Sprintf("%s-gen-key", opts.OwnerName)) + genKeyVolumeMount := corev1.VolumeMount{ + Name: genKeyVolume.Name, + ReadOnly: true, + MountPath: util.GenKeyPath, + } + omVolumeMounts = append(omVolumeMounts, genKeyVolumeMount) + omVolumes = append(omVolumes, genKeyVolume) + } + + if opts.QueryableBackupPemSecretName != "" { + queryablePemVolume := statefulset.CreateVolumeFromSecret("queryable-pem", opts.QueryableBackupPemSecretName) + omVolumeMounts = append(omVolumeMounts, corev1.VolumeMount{ + Name: queryablePemVolume.Name, + ReadOnly: true, + MountPath: "/certs/", + }) + omVolumes = append(omVolumes, queryablePemVolume) + } + + omHTTPSVolumeFunc := podtemplatespec.NOOP() + + if vault.IsVaultSecretBackend() { + if opts.HTTPSCertSecretName != "" { + vaultSecrets.TLSSecretName = opts.HTTPSCertSecretName + vaultSecrets.TLSHash = opts.CertHash + } + vaultSecrets.AppDBConnection = opts.AppDBConnectionSecretName + vaultSecrets.AppDBConnectionVolume = AppDBConnectionStringPath + } else if opts.HTTPSCertSecretName != "" { + + omHTTPSCertificateVolume := statefulset.CreateVolumeFromSecret("om-https-certificate", opts.HTTPSCertSecretName) + omHTTPSVolumeFunc = podtemplatespec.WithVolume(omHTTPSCertificateVolume) + omVolumeMounts = append(omVolumeMounts, corev1.VolumeMount{ + Name: omHTTPSCertificateVolume.Name, + MountPath: util.MmsPemKeyFileDirInContainer, + }) + + } + + appDbTLSConfigMapVolumeFunc := podtemplatespec.NOOP() + if opts.AppDBTlsCAConfigMapName != "" { + appDbTLSVolume := statefulset.CreateVolumeFromConfigMap("appdb-ca-certificate", opts.AppDBTlsCAConfigMapName) + appDbTLSConfigMapVolumeFunc = podtemplatespec.WithVolume(appDbTLSVolume) + omVolumeMounts = append(omVolumeMounts, corev1.VolumeMount{ + Name: appDbTLSVolume.Name, + MountPath: util.AppDBMmsCaFileDirInContainer, + }) + } + + podtemplateAnnotation := podtemplatespec.WithAnnotations(map[string]string{ + "connectionStringHash": opts.AppDBConnectionStringHash, + }) + + if vault.IsVaultSecretBackend() { + podtemplateAnnotation = podtemplatespec.Apply( + podtemplateAnnotation, + podtemplatespec.WithAnnotations( + vaultSecrets.OpsManagerAnnotations(opts.Namespace), + ), + ) + } + + if !vault.IsVaultSecretBackend() { + // configure the AppDB Connection String volume from a secret + mmsMongoUriVolume, mmsMongoUriVolumeMount := buildMmsMongoUriVolume(opts) + omVolumeMounts = append(omVolumeMounts, mmsMongoUriVolumeMount) + omVolumes = append(omVolumes, mmsMongoUriVolume) + } + + labels := defaultPodLabels(opts.ServiceName, opts.Name) + + // get the labels from the opts and append it to final labels + stsLabels := defaultPodLabels(opts.ServiceName, opts.Name) + for k, v := range opts.Labels { + stsLabels[k] = v + } + + omVolumes, omVolumeMounts = getNonPersistentOpsManagerVolumeMounts(omVolumes, omVolumeMounts, opts) + + opts.EnvVars = append(opts.EnvVars, kmipEnvVars(opts)...) + omVolumes, omVolumeMounts = appendKmipVolumes(omVolumes, omVolumeMounts, opts) + + return statefulset.Apply( + statefulset.WithLabels(stsLabels), + statefulset.WithMatchLabels(labels), + statefulset.WithName(opts.Name), + statefulset.WithNamespace(opts.Namespace), + statefulset.WithOwnerReference(opts.OwnerReference), + statefulset.WithReplicas(opts.Replicas), + statefulset.WithServiceName(opts.ServiceName), + statefulset.WithPodSpecTemplate( + podtemplatespec.Apply( + omHTTPSVolumeFunc, + appDbTLSConfigMapVolumeFunc, + podtemplateAnnotation, + podtemplatespec.WithVolumes(omVolumes), + configurePodSpecSecurityContext, + podtemplatespec.WithPodLabels(labels), + pullSecretsConfigurationFunc, + podtemplatespec.WithServiceAccount(util.OpsManagerServiceAccount), + podtemplatespec.WithAffinity(opts.Name, podAntiAffinityLabelKey, 100), + podtemplatespec.WithTopologyKey(util.DefaultAntiAffinityTopologyKey, 0), + podtemplatespec.WithInitContainerByIndex(0, + buildOpsManagerAndBackupInitContainer(), + ), + podtemplatespec.WithContainerByIndex(0, + container.Apply( + container.WithResourceRequirements(defaultOpsManagerResourceRequirements()), + container.WithPorts(buildOpsManagerContainerPorts(opts.HTTPSCertSecretName)), + container.WithImagePullPolicy(corev1.PullPolicy(env.ReadOrPanic(util.OpsManagerPullPolicy))), + container.WithImage(omImageURL), + container.WithEnvs(opts.EnvVars...), + container.WithEnvs(getOpsManagerHTTPSEnvVars(opts.HTTPSCertSecretName, opts.CertHash)...), + container.WithCommand([]string{"/opt/scripts/docker-entry-point.sh"}), + container.WithVolumeMounts(omVolumeMounts), + configureContainerSecurityContext, + ), + ), + ), + ), + ) +} + +func appendKmipVolumes(volumes []corev1.Volume, volumeMounts []corev1.VolumeMount, opts OpsManagerStatefulSetOptions) ([]corev1.Volume, []corev1.VolumeMount) { + if opts.kmip != nil { + volumes = append(volumes, statefulset.CreateVolumeFromConfigMap(util.KMIPServerCAName, opts.kmip.ServerConfiguration.CA)) + volumeMounts = append(volumeMounts, statefulset.CreateVolumeMount(util.KMIPServerCAName, util.KMIPServerCAHome, statefulset.WithReadOnly(true))) + + for _, cc := range opts.kmip.ClientConfigurations { + clientSecretName := util.KMIPClientSecretNamePrefix + cc.ClientCertificateSecretName + clientSecretPath := util.KMIPClientSecretsHome + "/" + cc.ClientCertificateSecretName + volumes = append(volumes, statefulset.CreateVolumeFromSecret(clientSecretName, cc.ClientCertificateSecretName)) + volumeMounts = append(volumeMounts, statefulset.CreateVolumeMount(clientSecretName, clientSecretPath, statefulset.WithReadOnly(true))) + } + } + return volumes, volumeMounts +} + +func kmipEnvVars(opts OpsManagerStatefulSetOptions) []corev1.EnvVar { + if opts.kmip != nil { + // At this point we are certain, this is correct. We checked it in kmipValidation + host, port, _ := net.SplitHostPort(opts.kmip.ServerConfiguration.URL) + return []corev1.EnvVar{ + { + Name: util.OmPropertyPrefix + "backup_kmip_server_host", + Value: host, + }, + { + Name: util.OmPropertyPrefix + "backup_kmip_server_port", + Value: port, + }, + { + Name: util.OmPropertyPrefix + "backup_kmip_server_ca_file", + Value: util.KMIPCAFileInContainer, + }, + } + } + return nil +} + +// opsManagerReadinessProbe creates the readiness probe. +// Note on 'PeriodSeconds': /monitor/health is a super lightweight method not doing any IO so we can make it more often. +func opsManagerReadinessProbe() probes.Modification { + return probes.Apply( + probes.WithInitialDelaySeconds(5), + probes.WithTimeoutSeconds(5), + probes.WithPeriodSeconds(5), + probes.WithSuccessThreshold(1), + probes.WithFailureThreshold(12), + probes.WithHandler(corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{Scheme: corev1.URISchemeHTTP, Port: intstr.FromInt(8080), Path: "/monitor/health"}, + }), + ) +} + +// opsManagerLivenessProbe creates the liveness probe. +func opsManagerLivenessProbe() probes.Modification { + return probes.Apply( + probes.WithInitialDelaySeconds(10), + probes.WithTimeoutSeconds(10), + probes.WithPeriodSeconds(30), + probes.WithSuccessThreshold(1), + probes.WithFailureThreshold(24), + probes.WithHandler(corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{Scheme: corev1.URISchemeHTTP, Port: intstr.FromInt(8080), Path: "/monitor/health"}, + }), + ) +} + +// opsManagerStartupProbe creates the startup probe. +func opsManagerStartupProbe() probes.Modification { + return probes.Apply( + probes.WithInitialDelaySeconds(1), + probes.WithTimeoutSeconds(10), + probes.WithPeriodSeconds(20), + probes.WithSuccessThreshold(1), + probes.WithFailureThreshold(30), + probes.WithHandler(corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{Scheme: corev1.URISchemeHTTP, Port: intstr.FromInt(8080), Path: "/monitor/health"}, + }), + ) +} + +// buildOpsManagerAndBackupInitContainer creates the init container which +// copies the entry point script in the OM/Backup container +func buildOpsManagerAndBackupInitContainer() container.Modification { + version := env.ReadOrDefault(util.InitOpsManagerVersion, "latest") + initContainerImageURL := ContainerImage(util.InitOpsManagerImageUrl, version, nil) + + _, configureContainerSecurityContext := podtemplatespec.WithDefaultSecurityContextsModifications() + + return container.Apply( + container.WithName(util.InitOpsManagerContainerName), + container.WithImage(initContainerImageURL), + container.WithVolumeMounts([]corev1.VolumeMount{buildOmScriptsVolumeMount(false)}), + configureContainerSecurityContext, + ) +} + +func buildOmScriptsVolumeMount(readOnly bool) corev1.VolumeMount { + return corev1.VolumeMount{ + Name: "ops-manager-scripts", + MountPath: "/opt/scripts", + ReadOnly: readOnly, + } +} + +func buildOpsManagerLifecycle() lifecycle.Modification { + return lifecycle.WithPrestopCommand([]string{"/bin/sh", "-c", "/mongodb-ops-manager/bin/mongodb-mms stop_mms"}) +} + +func getOpsManagerHTTPSEnvVars(httpsSecretName string, certHash string) []corev1.EnvVar { + if httpsSecretName != "" { + path := "server.pem" + if certHash != "" { + path = certHash + } + // Before creating the podTemplate, we need to add the new PemKeyFile + // configuration if required. + return []corev1.EnvVar{{ + Name: omv1.ConvertNameToEnvVarFormat(util.MmsPEMKeyFile), + Value: fmt.Sprintf("%s/%s", util.MmsPemKeyFileDirInContainer, path), + }} + } + return []corev1.EnvVar{} +} + +func defaultPodLabels(labelKey, antiAffinityKey string) map[string]string { + return map[string]string{ + appLabelKey: labelKey, + ControllerLabelName: util.OperatorName, + podAntiAffinityLabelKey: antiAffinityKey, + } +} + +// defaultOpsManagerResourceRequirements returns the default ResourceRequirements +// which are used by OpsManager and the BackupDaemon +func defaultOpsManagerResourceRequirements() corev1.ResourceRequirements { + defaultMemory, _ := resource.ParseQuantity(util.DefaultMemoryOpsManager) + return corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceMemory: defaultMemory, + }, + Requests: corev1.ResourceList{}, + } +} + +func buildOpsManagerContainerPorts(httpsCertSecretName string) []corev1.ContainerPort { + return []corev1.ContainerPort{{ContainerPort: int32(getOpsManagerContainerPort(httpsCertSecretName))}} +} + +func getOpsManagerContainerPort(httpsSecretName string) int { + _, port := omv1.SchemePortFromAnnotation("http") + if httpsSecretName != "" { + _, port = omv1.SchemePortFromAnnotation("https") + } + return port +} + +// opsManagerConfigurationToEnvVars returns a list of corev1.EnvVar which should be passed +// to the container running Ops Manager +func opsManagerConfigurationToEnvVars(m omv1.MongoDBOpsManager) []corev1.EnvVar { + var envVars []corev1.EnvVar + for name, value := range m.Spec.Configuration { + envVars = append(envVars, corev1.EnvVar{ + Name: omv1.ConvertNameToEnvVarFormat(name), Value: value, + }) + } + return envVars +} + +// postStartScriptCmd returns a command to run as postStart. +// +// It adds each certificate into JVM trust store with a random alias. +func postStartScriptCmd() string { + return fmt.Sprintf( + `awk -v cmd="%s/jdk/bin/keytool -noprompt -storepass changeit -import -trustcacerts -alias mongodb_operator_added_trust_ca_${RANDOM} -keystore %s/jdk/lib/security/cacerts" '/BEGIN/{close(cmd)};{print | cmd}' 2>&1 < %s/ca-pem`, MMSHome, MMSHome, util.AppDBMmsCaFileDirInContainer, + ) +} + +func hasReleasesVolumeMount(opts OpsManagerStatefulSetOptions) bool { + if opts.StatefulSetSpecOverride != nil { + for _, c := range opts.StatefulSetSpecOverride.Template.Spec.Containers { + for _, vm := range c.VolumeMounts { + if vm.MountPath == util.OpsManagerPvcMountDownloads { + return true + } + } + } + } + return false +} + +func getNonPersistentOpsManagerVolumeMounts(volumes []corev1.Volume, volumeMounts []corev1.VolumeMount, opts OpsManagerStatefulSetOptions) ([]corev1.Volume, []corev1.VolumeMount) { + volumes = append(volumes, statefulset.CreateVolumeFromEmptyDir(util.OpsManagerPvcNameData)) + + volumeMounts = append(volumeMounts, statefulset.CreateVolumeMount(util.OpsManagerPvcNameData, util.PvcMountPathTmp, statefulset.WithSubPath(util.PvcNameTmp))) + volumeMounts = append(volumeMounts, statefulset.CreateVolumeMount(util.OpsManagerPvcNameData, util.OpsManagerPvcMountPathTmp, statefulset.WithSubPath(util.OpsManagerPvcNameTmp))) + volumeMounts = append(volumeMounts, statefulset.CreateVolumeMount(util.OpsManagerPvcNameData, util.OpsManagerPvcMountPathLogs, statefulset.WithSubPath(util.OpsManagerPvcNameLogs))) + volumeMounts = append(volumeMounts, statefulset.CreateVolumeMount(util.OpsManagerPvcNameData, util.OpsManagerPvcMountPathEtc, statefulset.WithSubPath(util.OpsManagerPvcNameEtc))) + + // This content is used by the Ops Manager to download mongodbs. Mount it only if there's no downloads override (like in om_localmode-multiple-pv.yaml for example) + if !hasReleasesVolumeMount(opts) { + volumeMounts = append(volumeMounts, statefulset.CreateVolumeMount(util.OpsManagerPvcNameData, util.OpsManagerPvcMountDownloads, statefulset.WithSubPath(util.OpsManagerPvcNameDownloads))) + } + + // This content is populated by the docker-entry-point.sh. It's being copied from conf-template + volumeMounts = append(volumeMounts, statefulset.CreateVolumeMount(util.OpsManagerPvcNameData, util.OpsManagerPvcMountPathConf, statefulset.WithSubPath(util.OpsManagerPvcNameConf))) + + return volumes, volumeMounts +} diff --git a/controllers/operator/construct/opsmanager_construction_common.go b/controllers/operator/construct/opsmanager_construction_common.go new file mode 100644 index 000000000..ea62edfc3 --- /dev/null +++ b/controllers/operator/construct/opsmanager_construction_common.go @@ -0,0 +1,22 @@ +package construct + +import ( + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/statefulset" + corev1 "k8s.io/api/core/v1" +) + +const ( + AppDBConnectionStringVolume = "mongodb-uri" + AppDBConnectionStringPath = "/mongodb-ops-manager/.mongodb-mms-connection-string" +) + +func buildMmsMongoUriVolume(opts OpsManagerStatefulSetOptions) (corev1.Volume, corev1.VolumeMount) { + mmsMongoUriVolume := statefulset.CreateVolumeFromSecret(AppDBConnectionStringVolume, opts.AppDBConnectionSecretName) + mmsMongoUriVolumeMount := corev1.VolumeMount{ + Name: mmsMongoUriVolume.Name, + ReadOnly: true, + MountPath: AppDBConnectionStringPath, + } + + return mmsMongoUriVolume, mmsMongoUriVolumeMount +} diff --git a/controllers/operator/construct/opsmanager_construction_test.go b/controllers/operator/construct/opsmanager_construction_test.go new file mode 100644 index 000000000..c3b79a805 --- /dev/null +++ b/controllers/operator/construct/opsmanager_construction_test.go @@ -0,0 +1,456 @@ +package construct + +import ( + "fmt" + "testing" + + "k8s.io/utils/pointer" + + omv1 "github.com/10gen/ops-manager-kubernetes/api/v1/om" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/mock" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/secrets" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/util/merge" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/container" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/podtemplatespec" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/statefulset" + appsv1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/vault" + "github.com/stretchr/testify/assert" + "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" +) + +func init() { + logger, _ := zap.NewDevelopment() + zap.ReplaceGlobals(logger) +} + +func defaultSecretClient() secrets.SecretClient { + return secrets.SecretClient{ + VaultClient: &vault.VaultClient{}, + KubeClient: mock.NewClient(), + } +} + +func Test_buildOpsManagerandBackupInitContainer(t *testing.T) { + t.Setenv(util.InitOpsManagerImageUrl, "test-registry") + + modification := buildOpsManagerAndBackupInitContainer() + container := &corev1.Container{} + modification(container) + expectedVolumeMounts := []corev1.VolumeMount{{ + Name: "ops-manager-scripts", + MountPath: "/opt/scripts", + ReadOnly: false, + }} + expectedContainer := &corev1.Container{ + Name: util.InitOpsManagerContainerName, + Image: "test-registry:latest", + VolumeMounts: expectedVolumeMounts, + SecurityContext: &corev1.SecurityContext{ + ReadOnlyRootFilesystem: pointer.Bool(true), + AllowPrivilegeEscalation: pointer.Bool(false), + }, + } + assert.Equal(t, expectedContainer, container) + +} + +func TestBuildJvmParamsEnvVars_FromCustomContainerResource(t *testing.T) { + om := omv1.NewOpsManagerBuilderDefault(). + AddConfiguration(util.MmsCentralUrlPropKey, "http://om-svc"). + AddConfiguration("mms.adminEmailAddr", "cloud-manager-support@mongodb.com"). + Build() + om.Spec.JVMParams = []string{"-DFakeOptionEnabled"} + + omSts, err := OpsManagerStatefulSet(defaultSecretClient(), om, zap.S()) + assert.NoError(t, err) + template := omSts.Spec.Template + + unsetQuantity := *resource.NewQuantity(0, resource.BinarySI) + + template.Spec.Containers[0].Resources.Limits[corev1.ResourceMemory] = *resource.NewQuantity(268435456, resource.BinarySI) + template.Spec.Containers[0].Resources.Requests[corev1.ResourceMemory] = unsetQuantity + envVarLimitsOnly, err := buildJvmParamsEnvVars(om.Spec, util.OpsManagerContainerName, template) + assert.NoError(t, err) + + template.Spec.Containers[0].Resources.Requests[corev1.ResourceMemory] = *resource.NewQuantity(218435456, resource.BinarySI) + envVarLimitsAndReqs, err := buildJvmParamsEnvVars(om.Spec, util.OpsManagerContainerName, template) + assert.NoError(t, err) + + template.Spec.Containers[0].Resources.Limits[corev1.ResourceMemory] = unsetQuantity + envVarReqsOnly, err := buildJvmParamsEnvVars(om.Spec, util.OpsManagerContainerName, template) + assert.NoError(t, err) + + template.Spec.Containers[0].Resources.Requests[corev1.ResourceMemory] = unsetQuantity + envVarsNoLimitsOrReqs, err := buildJvmParamsEnvVars(om.Spec, util.OpsManagerContainerName, template) + assert.NoError(t, err) + + // if only memory requests are configured, xms and xmx should be 90% of mem request + assert.Equal(t, "-DFakeOptionEnabled -Xmx187m -Xms187m", envVarReqsOnly[0].Value) + // both are configured, xms should be 90% of mem request, and xmx 90% of mem limit + assert.Equal(t, "-DFakeOptionEnabled -Xmx230m -Xms187m", envVarLimitsAndReqs[0].Value) + // if only memory limits are configured, xms and xmx should be 90% of mem limits + assert.Equal(t, "-DFakeOptionEnabled -Xmx230m -Xms230m", envVarLimitsOnly[0].Value) + // if neither is configured, xms/xmx params should not be added to JVM params, keeping OM defaults + assert.Equal(t, "-DFakeOptionEnabled", envVarsNoLimitsOrReqs[0].Value) +} + +func TestBuildJvmParamsEnvVars_FromDefaultPodSpec(t *testing.T) { + om := omv1.NewOpsManagerBuilderDefault(). + AddConfiguration(util.MmsCentralUrlPropKey, "http://om-svc"). + AddConfiguration("mms.adminEmailAddr", "cloud-manager-support@mongodb.com"). + Build() + + omSts, err := OpsManagerStatefulSet(defaultSecretClient(), om, zap.S()) + assert.NoError(t, err) + template := omSts.Spec.Template + + envVar, err := buildJvmParamsEnvVars(om.Spec, util.OpsManagerContainerName, template) + assert.NoError(t, err) + // xmx and xms based calculated from default container memory, requests.mem=limits.mem=5GB + assert.Equal(t, "CUSTOM_JAVA_MMS_UI_OPTS", envVar[0].Name) + assert.Equal(t, "-Xmx4291m -Xms4291m", envVar[0].Value) + + assert.Equal(t, "CUSTOM_JAVA_DAEMON_OPTS", envVar[1].Name) + assert.Equal(t, "-Xmx4291m -Xms4291m -DDAEMON.DEBUG.PORT=8090", envVar[1].Value) +} + +func TestBuildOpsManagerStatefulSet(t *testing.T) { + t.Run("Env Vars Sorted", func(t *testing.T) { + om := omv1.NewOpsManagerBuilderDefault(). + AddConfiguration(util.MmsCentralUrlPropKey, "http://om-svc"). + AddConfiguration("mms.adminEmailAddr", "cloud-manager-support@mongodb.com"). + Build() + + sts, err := OpsManagerStatefulSet(defaultSecretClient(), om, zap.S()) + assert.NoError(t, err) + + // env vars are in sorted order + expectedVars := []corev1.EnvVar{ + {Name: "ENABLE_IRP", Value: "true"}, + {Name: "OM_PROP_mms_adminEmailAddr", Value: "cloud-manager-support@mongodb.com"}, + {Name: "OM_PROP_mms_centralUrl", Value: "http://om-svc"}, + {Name: "CUSTOM_JAVA_MMS_UI_OPTS", Value: "-Xmx4291m -Xms4291m"}, + {Name: "CUSTOM_JAVA_DAEMON_OPTS", Value: "-Xmx4291m -Xms4291m -DDAEMON.DEBUG.PORT=8090"}, + } + env := sts.Spec.Template.Spec.Containers[0].Env + assert.Equal(t, expectedVars, env) + }) + t.Run("JVM params applied after StatefulSet merge", func(t *testing.T) { + requirements := corev1.ResourceRequirements{ + Limits: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("6G")}, + Requests: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("400M")}, + } + + statefulSet := statefulset.New( + statefulset.WithPodSpecTemplate( + podtemplatespec.Apply( + podtemplatespec.WithContainer(util.OpsManagerContainerName, + container.Apply( + container.WithName(util.OpsManagerContainerName), + container.WithResourceRequirements(requirements), + ), + ), + ), + ), + ) + + om := omv1.NewOpsManagerBuilderDefault(). + SetStatefulSetSpec(statefulSet.Spec). + Build() + + sts, err := OpsManagerStatefulSet(defaultSecretClient(), om, zap.S()) + assert.NoError(t, err) + expectedVars := []corev1.EnvVar{ + {Name: "ENABLE_IRP", Value: "true"}, + {Name: "CUSTOM_JAVA_MMS_UI_OPTS", Value: "-Xmx5149m -Xms343m"}, + {Name: "CUSTOM_JAVA_DAEMON_OPTS", Value: "-Xmx5149m -Xms343m -DDAEMON.DEBUG.PORT=8090"}, + } + env := sts.Spec.Template.Spec.Containers[0].Env + assert.Equal(t, expectedVars, env) + }) + +} + +func Test_buildOpsManagerStatefulSet(t *testing.T) { + sts, err := OpsManagerStatefulSet(defaultSecretClient(), omv1.NewOpsManagerBuilderDefault().SetName("test-om").Build(), zap.S()) + assert.NoError(t, err) + assert.Equal(t, "test-om", sts.ObjectMeta.Name) + assert.Equal(t, util.OpsManagerContainerName, sts.Spec.Template.Spec.Containers[0].Name) + assert.Equal(t, []string{"/opt/scripts/docker-entry-point.sh"}, + sts.Spec.Template.Spec.Containers[0].Command) +} + +func Test_buildOpsManagerStatefulSet_Secrets(t *testing.T) { + opsManager := omv1.NewOpsManagerBuilderDefault().SetName("test-om").Build() + sts, err := OpsManagerStatefulSet(defaultSecretClient(), opsManager, zap.S()) + assert.NoError(t, err) + + expectedSecretVolumeNames := []string{"test-om-gen-key", opsManager.AppDBMongoConnectionStringSecretName()} + actualSecretVolumeNames := []string{} + for _, v := range sts.Spec.Template.Spec.Volumes { + if v.Secret != nil { + actualSecretVolumeNames = append(actualSecretVolumeNames, v.Secret.SecretName) + } + } + + assert.Equal(t, expectedSecretVolumeNames, actualSecretVolumeNames) +} + +// TestOpsManagerPodTemplate_MergePodTemplate checks the custom pod template provided by the user. +// It's supposed to override the values produced by the Operator and leave everything else as is +func TestOpsManagerPodTemplate_MergePodTemplate(t *testing.T) { + expectedAnnotations := map[string]string{"customKey": "customVal", "connectionStringHash": ""} + expectedTolerations := []corev1.Toleration{{Key: "dedicated", Value: "database"}} + newContainer := corev1.Container{ + Name: "my-custom-container", + Image: "my-custom-image", + } + + podTemplateSpec := podtemplatespec.New( + podtemplatespec.WithAnnotations(expectedAnnotations), + podtemplatespec.WithServiceAccount("test-account"), + podtemplatespec.WithTolerations(expectedTolerations), + podtemplatespec.WithContainer("my-custom-container", + container.Apply( + container.WithName("my-custom-container"), + container.WithImage("my-custom-image"), + )), + ) + + om := omv1.NewOpsManagerBuilderDefault().Build() + + omSts, err := OpsManagerStatefulSet(defaultSecretClient(), om, zap.S()) + assert.NoError(t, err) + template := omSts.Spec.Template + + originalLabels := template.Labels + + operatorSts := appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: template, + }, + } + + mergedSpec := merge.StatefulSetSpecs(operatorSts.Spec, appsv1.StatefulSetSpec{ + Template: podTemplateSpec, + }) + + template = mergedSpec.Template + // Service account gets overriden by custom pod template + assert.Equal(t, "test-account", template.Spec.ServiceAccountName) + assert.Equal(t, expectedAnnotations, template.Annotations) + assert.Equal(t, expectedTolerations, template.Spec.Tolerations) + assert.Len(t, template.Spec.Containers, 2) + assert.Equal(t, newContainer, template.Spec.Containers[1]) + + // Some validation that the Operator-made config hasn't suffered + assert.Equal(t, originalLabels, template.Labels) + assert.NotNil(t, template.Spec.SecurityContext) + assert.Equal(t, util.Int64Ref(util.FsGroup), template.Spec.SecurityContext.FSGroup) + assert.Equal(t, util.OpsManagerContainerName, template.Spec.Containers[0].Name) +} + +// TestOpsManagerPodTemplate_PodSpec verifies that StatefulSetSpec is applied correctly to OpsManager/Backup pod template. +func TestOpsManagerPodTemplate_PodSpec(t *testing.T) { + omSts, err := OpsManagerStatefulSet(defaultSecretClient(), omv1.NewOpsManagerBuilderDefault().Build(), zap.S()) + assert.NoError(t, err) + + resourceLimits := buildSafeResourceList("1.0", "500M") + resourceRequests := buildSafeResourceList("0.5", "400M") + nodeAffinity := defaultNodeAffinity() + podAffinity := defaultPodAffinity() + + stsSpecOverride := appsv1.StatefulSetSpec{ + Template: podtemplatespec.New( + podtemplatespec.WithAffinity(omSts.Name, PodAntiAffinityLabelKey, 100), + podtemplatespec.WithPodAffinity(&podAffinity), + podtemplatespec.WithNodeAffinity(&nodeAffinity), + podtemplatespec.WithTopologyKey("rack", 0), + podtemplatespec.WithContainer(util.OpsManagerContainerName, + container.Apply( + container.WithName(util.OpsManagerContainerName), + container.WithResourceRequirements(corev1.ResourceRequirements{ + Limits: resourceLimits, + Requests: resourceRequests, + }), + ), + ), + ), + } + mergedSpec := merge.StatefulSetSpecs(omSts.Spec, stsSpecOverride) + assert.NoError(t, err) + + spec := mergedSpec.Template.Spec + assert.Equal(t, defaultNodeAffinity(), *spec.Affinity.NodeAffinity) + assert.Equal(t, defaultPodAffinity(), *spec.Affinity.PodAffinity) + assert.Len(t, spec.Affinity.PodAntiAffinity.PreferredDuringSchedulingIgnoredDuringExecution, 1) + // the pod anti affinity term was overridden + term := spec.Affinity.PodAntiAffinity.PreferredDuringSchedulingIgnoredDuringExecution[0] + assert.Equal(t, "rack", term.PodAffinityTerm.TopologyKey) + + req := spec.Containers[0].Resources.Limits + assert.Len(t, req, 2) + cpu := req[corev1.ResourceCPU] + memory := req[corev1.ResourceMemory] + assert.Equal(t, "1", (&cpu).String()) + assert.Equal(t, int64(500000000), (&memory).Value()) + + req = spec.Containers[0].Resources.Requests + assert.Len(t, req, 2) + cpu = req[corev1.ResourceCPU] + memory = req[corev1.ResourceMemory] + assert.Equal(t, "500m", (&cpu).String()) + assert.Equal(t, int64(400000000), (&memory).Value()) +} + +// TestOpsManagerPodTemplate_SecurityContext verifies that security context is created correctly +// in OpsManager/BackupDaemon podTemplate. It's not built if 'MANAGED_SECURITY_CONTEXT' env var +// is set to 'true' +func TestOpsManagerPodTemplate_SecurityContext(t *testing.T) { + defer mock.InitDefaultEnvVariables() + + omSts, err := OpsManagerStatefulSet(defaultSecretClient(), omv1.NewOpsManagerBuilderDefault().Build(), zap.S()) + assert.NoError(t, err) + + podSpecTemplate := omSts.Spec.Template + spec := podSpecTemplate.Spec + assert.Len(t, spec.InitContainers, 1) + assert.Equal(t, spec.InitContainers[0].Name, "mongodb-enterprise-init-ops-manager") + assert.NotNil(t, spec.SecurityContext) + assert.Equal(t, util.Int64Ref(util.FsGroup), spec.SecurityContext.FSGroup) + + t.Setenv(util.ManagedSecurityContextEnv, "true") + + omSts, err = OpsManagerStatefulSet(defaultSecretClient(), omv1.NewOpsManagerBuilderDefault().Build(), zap.S()) + assert.NoError(t, err) + podSpecTemplate = omSts.Spec.Template + assert.Nil(t, podSpecTemplate.Spec.SecurityContext) +} + +func TestOpsManagerPodTemplate_TerminationTimeout(t *testing.T) { + omSts, err := OpsManagerStatefulSet(defaultSecretClient(), omv1.NewOpsManagerBuilderDefault().Build(), zap.S()) + assert.NoError(t, err) + podSpecTemplate := omSts.Spec.Template + assert.Equal(t, int64(300), *podSpecTemplate.Spec.TerminationGracePeriodSeconds) +} + +func TestOpsManagerPodTemplate_ImagePullPolicy(t *testing.T) { + defer mock.InitDefaultEnvVariables() + + omSts, err := OpsManagerStatefulSet(defaultSecretClient(), omv1.NewOpsManagerBuilderDefault().Build(), zap.S()) + assert.NoError(t, err) + + podSpecTemplate := omSts.Spec.Template + spec := podSpecTemplate.Spec + + assert.Nil(t, spec.ImagePullSecrets) + + t.Setenv(util.ImagePullSecrets, "my-cool-secret") + omSts, err = OpsManagerStatefulSet(defaultSecretClient(), omv1.NewOpsManagerBuilderDefault().Build(), zap.S()) + assert.NoError(t, err) + podSpecTemplate = omSts.Spec.Template + spec = podSpecTemplate.Spec + + assert.NotNil(t, spec.ImagePullSecrets) + assert.Equal(t, spec.ImagePullSecrets[0].Name, "my-cool-secret") +} + +// TestOpsManagerPodTemplate_Container verifies the default OM container built by 'opsManagerPodTemplate' method +func TestOpsManagerPodTemplate_Container(t *testing.T) { + om := omv1.NewOpsManagerBuilderDefault().SetVersion("4.2.0").Build() + sts, err := OpsManagerStatefulSet(defaultSecretClient(), om, zap.S()) + assert.NoError(t, err) + template := sts.Spec.Template + + assert.Len(t, template.Spec.Containers, 1) + container := template.Spec.Containers[0] + assert.Equal(t, util.OpsManagerContainerName, container.Name) + // TODO change when we stop using versioning + assert.Equal(t, "quay.io/mongodb/mongodb-enterprise-ops-manager:4.2.0", container.Image) + assert.Equal(t, corev1.PullNever, container.ImagePullPolicy) + + assert.Equal(t, int32(util.OpsManagerDefaultPortHTTP), container.Ports[0].ContainerPort) + assert.Equal(t, "/monitor/health", container.ReadinessProbe.ProbeHandler.HTTPGet.Path) + assert.Equal(t, int32(8080), container.ReadinessProbe.ProbeHandler.HTTPGet.Port.IntVal) + assert.Equal(t, "/monitor/health", container.LivenessProbe.ProbeHandler.HTTPGet.Path) + assert.Equal(t, int32(8080), container.LivenessProbe.ProbeHandler.HTTPGet.Port.IntVal) + assert.Equal(t, "/monitor/health", container.StartupProbe.ProbeHandler.HTTPGet.Path) + assert.Equal(t, int32(8080), container.StartupProbe.ProbeHandler.HTTPGet.Port.IntVal) + + assert.Equal(t, []string{"/opt/scripts/docker-entry-point.sh"}, container.Command) + assert.Equal(t, []string{"/bin/sh", "-c", "/mongodb-ops-manager/bin/mongodb-mms stop_mms"}, + container.Lifecycle.PreStop.Exec.Command) +} + +func Test_OpsManagerStatefulSetWithRelatedImages(t *testing.T) { + initOpsManagerRelatedImageEnv := fmt.Sprintf("RELATED_IMAGE_%s_1_2_3", util.InitOpsManagerImageUrl) + opsManagerRelatedImageEnv := fmt.Sprintf("RELATED_IMAGE_%s_5_0_0", util.OpsManagerImageUrl) + + t.Setenv(util.InitOpsManagerImageUrl, "quay.io/mongodb/mongodb-enterprise-init-ops-manager") + t.Setenv(util.InitOpsManagerVersion, "1.2.3") + t.Setenv(util.OpsManagerImageUrl, "quay.io/mongodb/mongodb-enterprise-ops-manager") + t.Setenv(initOpsManagerRelatedImageEnv, "quay.io/mongodb/mongodb-enterprise-init-ops-manager:@sha256:MONGODB_INIT_APPDB") + t.Setenv(opsManagerRelatedImageEnv, "quay.io/mongodb/mongodb-enterprise-ops-manager:@sha256:MONGODB_OPS_MANAGER") + + sts, err := OpsManagerStatefulSet(defaultSecretClient(), omv1.NewOpsManagerBuilderDefault().SetName("test-om").SetVersion("5.0.0").Build(), zap.S()) + assert.NoError(t, err) + assert.Equal(t, "quay.io/mongodb/mongodb-enterprise-init-ops-manager:@sha256:MONGODB_INIT_APPDB", sts.Spec.Template.Spec.InitContainers[0].Image) + assert.Equal(t, "quay.io/mongodb/mongodb-enterprise-ops-manager:@sha256:MONGODB_OPS_MANAGER", sts.Spec.Template.Spec.Containers[0].Image) +} + +func defaultNodeAffinity() corev1.NodeAffinity { + return corev1.NodeAffinity{ + RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{ + NodeSelectorTerms: []corev1.NodeSelectorTerm{{ + MatchExpressions: []corev1.NodeSelectorRequirement{{ + Key: "dc", + Values: []string{"US-EAST"}, + }}}, + }}, + } +} +func defaultPodAffinity() corev1.PodAffinity { + return corev1.PodAffinity{ + PreferredDuringSchedulingIgnoredDuringExecution: []corev1.WeightedPodAffinityTerm{{ + Weight: 50, + PodAffinityTerm: corev1.PodAffinityTerm{ + LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "web-server"}}, + TopologyKey: "rack", + }, + }}} +} + +func defaultPodAntiAffinity() corev1.PodAntiAffinity { + return corev1.PodAntiAffinity{ + PreferredDuringSchedulingIgnoredDuringExecution: []corev1.WeightedPodAffinityTerm{{ + Weight: 77, + PodAffinityTerm: corev1.PodAffinityTerm{ + LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "web-server"}}, + TopologyKey: "rack", + }, + }}} +} + +// buildSafeResourceList returns a ResourceList but should not be called +// with dynamic values. This function ignores errors in the parsing of +// resource.Quantities and as a result should only be used in tests with +// pre-set valid values. +func buildSafeResourceList(cpu, memory string) corev1.ResourceList { + res := corev1.ResourceList{} + if q := ParseQuantityOrZero(cpu); !q.IsZero() { + res[corev1.ResourceCPU] = q + } + if q := ParseQuantityOrZero(memory); !q.IsZero() { + res[corev1.ResourceMemory] = q + } + return res +} diff --git a/controllers/operator/construct/pvc.go b/controllers/operator/construct/pvc.go new file mode 100644 index 000000000..ef02d1836 --- /dev/null +++ b/controllers/operator/construct/pvc.go @@ -0,0 +1,57 @@ +package construct + +import ( + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/persistentvolumeclaim" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/statefulset" + corev1 "k8s.io/api/core/v1" +) + +// pvcFunc convenience function to build a PersistentVolumeClaim. It accepts two config parameters - the one specified by +// the customers and the default one configured by the Operator. Putting the default one to the signature ensures the +// calling code doesn't forget to think about default values in case the user hasn't provided values. +func pvcFunc(name string, config *mdbv1.PersistenceConfig, defaultConfig mdbv1.PersistenceConfig, labels map[string]string) persistentvolumeclaim.Modification { + selectorFunc := persistentvolumeclaim.NOOP() + storageClassNameFunc := persistentvolumeclaim.NOOP() + if config != nil { + if config.LabelSelector != nil { + selectorFunc = persistentvolumeclaim.WithLabelSelector(&config.LabelSelector.LabelSelector) + } + if config.StorageClass != nil { + storageClassNameFunc = persistentvolumeclaim.WithStorageClassName(*config.StorageClass) + } + } + return persistentvolumeclaim.Apply( + persistentvolumeclaim.WithName(name), + persistentvolumeclaim.WithAccessModes(corev1.ReadWriteOnce), + persistentvolumeclaim.WithResourceRequests(buildStorageRequirements(config, defaultConfig)), + persistentvolumeclaim.WithLabels(labels), + selectorFunc, + storageClassNameFunc, + ) +} + +func createClaimsAndMountsMultiModeFunc(persistence *mdbv1.Persistence, defaultConfig mdbv1.MultiplePersistenceConfig, labels map[string]string) (map[string]persistentvolumeclaim.Modification, []corev1.VolumeMount) { + mounts := []corev1.VolumeMount{ + statefulset.CreateVolumeMount(util.PvcNameData, util.PvcMountPathData), + statefulset.CreateVolumeMount(util.PvcNameJournal, util.PvcMountPathJournal), + statefulset.CreateVolumeMount(util.PvcNameLogs, util.PvcMountPathLogs), + } + return map[string]persistentvolumeclaim.Modification{ + util.PvcNameData: pvcFunc(util.PvcNameData, persistence.MultipleConfig.Data, *defaultConfig.Data, labels), + util.PvcNameJournal: pvcFunc(util.PvcNameJournal, persistence.MultipleConfig.Journal, *defaultConfig.Journal, labels), + util.PvcNameLogs: pvcFunc(util.PvcNameLogs, persistence.MultipleConfig.Logs, *defaultConfig.Logs, labels), + }, mounts +} + +func createClaimsAndMountsSingleModeFunc(config *mdbv1.PersistenceConfig, opts DatabaseStatefulSetOptions) (map[string]persistentvolumeclaim.Modification, []corev1.VolumeMount) { + mounts := []corev1.VolumeMount{ + statefulset.CreateVolumeMount(util.PvcNameData, util.PvcMountPathData, statefulset.WithSubPath(util.PvcNameData)), + statefulset.CreateVolumeMount(util.PvcNameData, util.PvcMountPathJournal, statefulset.WithSubPath(util.PvcNameJournal)), + statefulset.CreateVolumeMount(util.PvcNameData, util.PvcMountPathLogs, statefulset.WithSubPath(util.PvcNameLogs)), + } + return map[string]persistentvolumeclaim.Modification{ + util.PvcNameData: pvcFunc(util.PvcNameData, config, *opts.PodSpec.Default.Persistence.SingleConfig, opts.Labels), + }, mounts +} diff --git a/controllers/operator/construct/resourcerequirements.go b/controllers/operator/construct/resourcerequirements.go new file mode 100644 index 000000000..554067e79 --- /dev/null +++ b/controllers/operator/construct/resourcerequirements.go @@ -0,0 +1,69 @@ +package construct + +import ( + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" +) + +// buildStorageRequirements returns a corev1.ResourceList definition for storage requirements. +// This is used by the StatefulSet PersistentVolumeClaimTemplate. +func buildStorageRequirements(persistenceConfig *mdbv1.PersistenceConfig, defaultConfig mdbv1.PersistenceConfig) corev1.ResourceList { + res := corev1.ResourceList{} + + if q := ParseQuantityOrZero(mdbv1.GetStorageOrDefault(persistenceConfig, defaultConfig)); !q.IsZero() { + res[corev1.ResourceStorage] = q + } + + return res +} + +// buildRequirementsFromPodSpec takes a podSpec, and builds a ResourceRequirements +// taking into consideration the default values of the given podSpec +func buildRequirementsFromPodSpec(podSpec mdbv1.PodSpecWrapper) corev1.ResourceRequirements { + return corev1.ResourceRequirements{ + Limits: buildLimitsRequirements(&podSpec), + Requests: buildRequestsRequirements(&podSpec), + } +} + +// buildLimitsRequirements returns a corev1.ResourceList definition for limits for CPU and Memory Requirements +// This is used by the StatefulSet containers to allocate resources per Pod. +func buildLimitsRequirements(reqs *mdbv1.PodSpecWrapper) corev1.ResourceList { + res := corev1.ResourceList{} + + if q := ParseQuantityOrZero(reqs.GetCpuOrDefault()); !q.IsZero() { + res[corev1.ResourceCPU] = q + } + if q := ParseQuantityOrZero(reqs.GetMemoryOrDefault()); !q.IsZero() { + res[corev1.ResourceMemory] = q + } + + return res +} + +// buildRequestsRequirements returns a corev1.ResourceList definition for requests for CPU and Memory Requirements +// This is used by the StatefulSet containers to allocate resources per Pod. +func buildRequestsRequirements(reqs *mdbv1.PodSpecWrapper) corev1.ResourceList { + res := corev1.ResourceList{} + + if q := ParseQuantityOrZero(reqs.GetCpuRequestsOrDefault()); !q.IsZero() { + res[corev1.ResourceCPU] = q + } + if q := ParseQuantityOrZero(reqs.GetMemoryRequestsOrDefault()); !q.IsZero() { + res[corev1.ResourceMemory] = q + } + + return res +} + +// TODO: this function needs to be unexported - refactor tests and make this private +func ParseQuantityOrZero(qty string) resource.Quantity { + q, err := resource.ParseQuantity(qty) + if err != nil && qty != "" { + zap.S().Infof("Error converting %s to `resource.Quantity`", qty) + } + + return q +} diff --git a/controllers/operator/construct/resourcerequirements_test.go b/controllers/operator/construct/resourcerequirements_test.go new file mode 100644 index 000000000..ee30bd161 --- /dev/null +++ b/controllers/operator/construct/resourcerequirements_test.go @@ -0,0 +1,113 @@ +package construct + +import ( + "testing" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/stretchr/testify/assert" + "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" +) + +func init() { + logger, _ := zap.NewDevelopment() + zap.ReplaceGlobals(logger) +} + +func TestStorageRequirements(t *testing.T) { + // value is provided - the default is ignored + podSpec := mdbv1.NewEmptyPodSpecWrapperBuilder(). + SetSinglePersistence(mdbv1.NewPersistenceBuilder("40G")). + SetDefault(mdbv1.NewPodSpecWrapperBuilder().SetSinglePersistence(mdbv1.NewPersistenceBuilder("12G"))). + Build() + + req := buildStorageRequirements(podSpec.Persistence.SingleConfig, *podSpec.Default.Persistence.SingleConfig) + + assert.Len(t, req, 1) + quantity := req[corev1.ResourceStorage] + assert.Equal(t, int64(40000000000), (&quantity).Value()) + + // value is not provided - the default is used + podSpec = mdbv1.NewEmptyPodSpecWrapperBuilder(). + SetDefault(mdbv1.NewPodSpecWrapperBuilder().SetSinglePersistence(mdbv1.NewPersistenceBuilder("5G"))). + Build() + + req = buildStorageRequirements(podSpec.Persistence.SingleConfig, *podSpec.Default.Persistence.SingleConfig) + + assert.Len(t, req, 1) + quantity = req[corev1.ResourceStorage] + assert.Equal(t, int64(5000000000), (&quantity).Value()) + + // value is not provided and default is empty - the parameter must not be set at all + podSpec = mdbv1.NewEmptyPodSpecWrapperBuilder().Build() + req = buildStorageRequirements(podSpec.Persistence.SingleConfig, *podSpec.Default.Persistence.SingleConfig) + + assert.Len(t, req, 0) +} + +func TestBuildLimitsRequirements(t *testing.T) { + // values are provided - the defaults are ignored + podSpec := mdbv1.NewEmptyPodSpecWrapperBuilder().SetCpuLimit("0.1").SetMemoryLimit("512M"). + SetDefault(mdbv1.NewPodSpecWrapperBuilder().SetCpuLimit("0.5").SetMemoryLimit("1G")). + Build() + + req := buildLimitsRequirements(podSpec) + + assert.Len(t, req, 2) + cpu := req[corev1.ResourceCPU] + memory := req[corev1.ResourceMemory] + assert.Equal(t, "100m", (&cpu).String()) + assert.Equal(t, int64(512000000), (&memory).Value()) + + // values are not provided - the defaults are used + podSpec = mdbv1.NewEmptyPodSpecWrapperBuilder(). + SetDefault(mdbv1.NewPodSpecWrapperBuilder().SetCpuLimit("0.8").SetMemoryLimit("10G")). + Build() + + req = buildLimitsRequirements(podSpec) + + assert.Len(t, req, 2) + cpu = req[corev1.ResourceCPU] + memory = req[corev1.ResourceMemory] + assert.Equal(t, "800m", (&cpu).String()) + assert.Equal(t, int64(10000000000), (&memory).Value()) + + // value are not provided and default are empty - the parameters must not be set at all + podSpec = mdbv1.NewEmptyPodSpecWrapperBuilder().Build() + req = buildLimitsRequirements(podSpec) + + assert.Len(t, req, 0) +} + +func TestBuildRequestsRequirements(t *testing.T) { + // values are provided - the defaults are ignored + podSpec := mdbv1.NewEmptyPodSpecWrapperBuilder().SetCpuRequests("0.1").SetMemoryRequest("512M"). + SetDefault(mdbv1.NewPodSpecWrapperBuilder().SetCpuRequests("0.5").SetMemoryRequest("1G")). + Build() + + req := buildRequestsRequirements(podSpec) + + assert.Len(t, req, 2) + cpu := req[corev1.ResourceCPU] + memory := req[corev1.ResourceMemory] + assert.Equal(t, "100m", (&cpu).String()) + assert.Equal(t, int64(512000000), (&memory).Value()) + + // values are not provided - the defaults are used + podSpec = mdbv1.NewEmptyPodSpecWrapperBuilder(). + SetDefault(mdbv1.NewPodSpecWrapperBuilder().SetCpuRequests("0.8").SetMemoryRequest("10G")). + Build() + req = buildRequestsRequirements(podSpec) + + assert.Len(t, req, 2) + cpu = req[corev1.ResourceCPU] + memory = req[corev1.ResourceMemory] + assert.Equal(t, "800m", (&cpu).String()) + assert.Equal(t, int64(10000000000), (&memory).Value()) + + // value are not provided and default are empty - the parameters must not be set at all + podSpec = mdbv1.NewEmptyPodSpecWrapperBuilder().Build() + req = buildRequestsRequirements(podSpec) + + assert.Len(t, req, 0) +} diff --git a/controllers/operator/construct/testing_utils.go b/controllers/operator/construct/testing_utils.go new file mode 100644 index 000000000..0c6ebb99a --- /dev/null +++ b/controllers/operator/construct/testing_utils.go @@ -0,0 +1,12 @@ +package construct + +import ( + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" +) + +func GetPodEnvOptions() func(options *DatabaseStatefulSetOptions) { + return func(options *DatabaseStatefulSetOptions) { + options.PodVars = &env.PodEnvVars{ProjectID: "abcd"} + + } +} diff --git a/controllers/operator/controlledfeature/controlled_feature.go b/controllers/operator/controlledfeature/controlled_feature.go new file mode 100644 index 000000000..1875e69c2 --- /dev/null +++ b/controllers/operator/controlledfeature/controlled_feature.go @@ -0,0 +1,112 @@ +package controlledfeature + +import ( + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/workflow" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/util/versionutil" + "go.uber.org/zap" +) + +type ControlledFeature struct { + ManagementSystem ManagementSystem `json:"externalManagementSystem"` + Policies []Policy `json:"policies"` +} + +type ManagementSystem struct { + Name string `json:"name"` + Version string `json:"version"` +} + +type PolicyType string + +const ( + ExternallyManaged PolicyType = "EXTERNALLY_MANAGED_LOCK" + DisableAuthenticationMechanisms PolicyType = "DISABLE_AUTHENTICATION_MECHANISMS" + DisableMongodConfig PolicyType = "DISABLE_SET_MONGOD_CONFIG" + DisableMongodVersion PolicyType = "DISABLE_SET_MONGOD_VERSION" +) + +type Policy struct { + PolicyType PolicyType `json:"policy"` + DisabledParams []string `json:"disabledParams"` +} + +func newControlledFeature(options ...func(*ControlledFeature)) *ControlledFeature { + cf := &ControlledFeature{ + ManagementSystem: ManagementSystem{ + Name: util.OperatorName, + Version: util.OperatorVersion, + }, + } + + for _, op := range options { + op(cf) + } + + return cf +} + +func OptionExternallyManaged(cf *ControlledFeature) { + cf.Policies = append(cf.Policies, Policy{PolicyType: ExternallyManaged, DisabledParams: make([]string, 0)}) +} + +func OptionDisableAuthenticationMechanism(cf *ControlledFeature) { + cf.Policies = append(cf.Policies, Policy{PolicyType: DisableAuthenticationMechanisms, DisabledParams: make([]string, 0)}) +} + +func OptionDisableMongodbConfig(disabledParams []string) func(*ControlledFeature) { + return func(cf *ControlledFeature) { + cf.Policies = append(cf.Policies, Policy{PolicyType: DisableMongodConfig, DisabledParams: disabledParams}) + } +} + +func OptionDisableMongodbVersion(cf *ControlledFeature) { + cf.Policies = append(cf.Policies, Policy{PolicyType: DisableMongodVersion}) +} + +type Updater interface { + UpdateControlledFeature(cf *ControlledFeature) error +} + +type Getter interface { + GetControlledFeature() (*ControlledFeature, error) +} + +// EnsureFeatureControls updates the controlled feature based on the provided MongoDB +// resource if the version of Ops Manager supports it +func EnsureFeatureControls(mdb mdbv1.MongoDB, updater Updater, omVersion versionutil.OpsManagerVersion, log *zap.SugaredLogger) workflow.Status { + if !ShouldUseFeatureControls(omVersion) { + log.Debugf("Ops Manager version is %s, which does not support Feature Controls API", omVersion) + return workflow.OK() + } + + cf := buildFeatureControlsByMdb(mdb) + log.Debug("Configuring feature controls") + if err := updater.UpdateControlledFeature(cf); err != nil { + return workflow.Failed(err) + } + return workflow.OK() +} + +// ShouldUseFeatureControls returns a boolean indicating if the feature controls +// should be enabled for the given version of Ops Manager +func ShouldUseFeatureControls(version versionutil.OpsManagerVersion) bool { + + // if we were not successfully able to determine a version + // from Ops Manager, we can assume it is a legacy version + if version.IsUnknown() { + return false + } + + // feature controls are enabled on Cloud Manager, e.g. v20191112 + if version.IsCloudManager() { + return true + } + + if _, err := version.Semver(); err != nil { + return false + } + + return true +} diff --git a/controllers/operator/controlledfeature/controlled_feature_test.go b/controllers/operator/controlledfeature/controlled_feature_test.go new file mode 100644 index 000000000..194f60455 --- /dev/null +++ b/controllers/operator/controlledfeature/controlled_feature_test.go @@ -0,0 +1,31 @@ +package controlledfeature + +import ( + "fmt" + "testing" + + "github.com/10gen/ops-manager-kubernetes/pkg/util/versionutil" + "github.com/stretchr/testify/assert" +) + +func TestShouldUseFeatureControls(t *testing.T) { + + // All OM versions that we support now support feature controls + assert.True(t, ShouldUseFeatureControls(toOMVersion("4.4.0"))) + assert.True(t, ShouldUseFeatureControls(toOMVersion("4.4.1"))) + assert.True(t, ShouldUseFeatureControls(toOMVersion("5.0.1"))) + + // Cloud Manager also supports it + assert.True(t, ShouldUseFeatureControls(toOMVersion("v20020201"))) + +} + +func toOMVersion(versionString string) versionutil.OpsManagerVersion { + if versionString == "" { + return versionutil.OpsManagerVersion{} + } + + return versionutil.OpsManagerVersion{ + VersionString: fmt.Sprintf("%s.56729.20191105T2247Z", versionString), + } +} diff --git a/controllers/operator/controlledfeature/feature_by_mdb.go b/controllers/operator/controlledfeature/feature_by_mdb.go new file mode 100644 index 000000000..02d8bab1a --- /dev/null +++ b/controllers/operator/controlledfeature/feature_by_mdb.go @@ -0,0 +1,60 @@ +package controlledfeature + +import ( + "sort" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" +) + +// buildFeatureControlsByMdb builds the controlled feature by MongoDB resource +func buildFeatureControlsByMdb(mdb mdbv1.MongoDB) *ControlledFeature { + var cf *ControlledFeature + controlledFeatures := []func(*ControlledFeature){OptionExternallyManaged} + controlledFeatures = append(controlledFeatures, authentication(mdb)...) + controlledFeatures = append(controlledFeatures, mongodbParams(mdb)...) + controlledFeatures = append(controlledFeatures, mdbVersion()...) + + cf = newControlledFeature(controlledFeatures...) + + return cf +} + +// mongodbParams enables mongodb options policy if any of additional mongod configurations are specified for MongoDB spec +func mongodbParams(mdb mdbv1.MongoDB) []func(*ControlledFeature) { + var disabledMongodbParams []string + disabledMongodbParams = append(disabledMongodbParams, mdb.Spec.AdditionalMongodConfig.ToFlatList()...) + if mdb.Spec.MongosSpec != nil { + disabledMongodbParams = append(disabledMongodbParams, mdb.Spec.MongosSpec.AdditionalMongodConfig.ToFlatList()...) + } + if mdb.Spec.ConfigSrvSpec != nil { + disabledMongodbParams = append(disabledMongodbParams, mdb.Spec.ConfigSrvSpec.AdditionalMongodConfig.ToFlatList()...) + } + if mdb.Spec.ShardSpec != nil { + disabledMongodbParams = append(disabledMongodbParams, mdb.Spec.ShardSpec.AdditionalMongodConfig.ToFlatList()...) + } + if len(disabledMongodbParams) > 0 { + // We need to ensure no duplicates + var deduplicatedParams []string + for _, v := range disabledMongodbParams { + if !stringutil.Contains(deduplicatedParams, v) { + deduplicatedParams = append(deduplicatedParams, v) + } + } + sort.Strings(deduplicatedParams) + return []func(*ControlledFeature){OptionDisableMongodbConfig(deduplicatedParams)} + } + return []func(*ControlledFeature){} +} + +// authentication enables authentication feature only if Authentication is enabled in mdb +func authentication(mdb mdbv1.MongoDB) []func(*ControlledFeature) { + if mdb.Spec.Security.Authentication != nil { + return []func(*ControlledFeature){OptionDisableAuthenticationMechanism} + } + return []func(*ControlledFeature){} +} + +func mdbVersion() []func(*ControlledFeature) { + return []func(*ControlledFeature){OptionDisableMongodbVersion} +} diff --git a/controllers/operator/controlledfeature/feature_by_mdb_test.go b/controllers/operator/controlledfeature/feature_by_mdb_test.go new file mode 100644 index 000000000..c945cae7b --- /dev/null +++ b/controllers/operator/controlledfeature/feature_by_mdb_test.go @@ -0,0 +1,60 @@ +package controlledfeature + +import ( + "testing" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/stretchr/testify/assert" +) + +func TestBuildFeatureControlsByMdb_MongodbParams(t *testing.T) { + t.Run("Feature controls for replica set additional params", func(t *testing.T) { + config := mdbv1.NewAdditionalMongodConfig("storage.journal.enabled", true).AddOption("storage.indexBuildRetry", true) + rs := mdbv1.NewReplicaSetBuilder().SetAdditionalConfig(config).Build() + controlledFeature := buildFeatureControlsByMdb(*rs) + + expectedControlledFeature := &ControlledFeature{ + ManagementSystem: ManagementSystem{ + Name: util.OperatorName, + Version: util.OperatorVersion, + }, + Policies: []Policy{ + {PolicyType: ExternallyManaged, DisabledParams: make([]string, 0)}, + {PolicyType: DisableMongodConfig, + DisabledParams: []string{"storage.indexBuildRetry", "storage.journal.enabled"}, + }, + {PolicyType: DisableMongodVersion}, + }, + } + assert.Equal(t, expectedControlledFeature, controlledFeature) + }) + t.Run("Feature controls for sharded cluster additional params", func(t *testing.T) { + shardConfig := mdbv1.NewAdditionalMongodConfig("storage.journal.enabled", true).AddOption("storage.indexBuildRetry", true) + mongosConfig := mdbv1.NewAdditionalMongodConfig("systemLog.verbosity", 2) + configSrvConfig := mdbv1.NewAdditionalMongodConfig("systemLog.verbosity", 5).AddOption("systemLog.traceAllExceptions", true) + + rs := mdbv1.NewClusterBuilder(). + SetShardAdditionalConfig(shardConfig). + SetMongosAdditionalConfig(mongosConfig). + SetConfigSrvAdditionalConfig(configSrvConfig). + Build() + controlledFeature := buildFeatureControlsByMdb(*rs) + + expectedControlledFeature := &ControlledFeature{ + ManagementSystem: ManagementSystem{ + Name: util.OperatorName, + Version: util.OperatorVersion, + }, + Policies: []Policy{ + {PolicyType: ExternallyManaged, DisabledParams: make([]string, 0)}, + {PolicyType: DisableMongodConfig, + // The options have been deduplicated and contain the list of all options for each sharded cluster member + DisabledParams: []string{"storage.indexBuildRetry", "storage.journal.enabled", "systemLog.traceAllExceptions", "systemLog.verbosity"}, + }, + {PolicyType: DisableMongodVersion}, + }, + } + assert.Equal(t, expectedControlledFeature, controlledFeature) + }) +} diff --git a/controllers/operator/create/create.go b/controllers/operator/create/create.go new file mode 100644 index 000000000..3d5eda46b --- /dev/null +++ b/controllers/operator/create/create.go @@ -0,0 +1,309 @@ +package create + +import ( + v1 "github.com/10gen/ops-manager-kubernetes/api/v1" + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + omv1 "github.com/10gen/ops-manager-kubernetes/api/v1/om" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/construct" + "github.com/10gen/ops-manager-kubernetes/pkg/dns" + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + enterprisests "github.com/10gen/ops-manager-kubernetes/pkg/statefulset" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + kubernetesClient "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/client" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/service" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/util/merge" + "go.uber.org/zap" + "golang.org/x/xerrors" + appsv1 "k8s.io/api/apps/v1" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/utils/pointer" + "sigs.k8s.io/controller-runtime/pkg/client" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" +) + +var ( + externalConnectivityPortName = "external-connectivity-port" + internalConnectivityPortName = "internal-connectivity-port" + backupPortName = "backup-port" + appLabelKey = "app" + podNameLabelKey = "statefulset.kubernetes.io/pod-name" +) + +// DatabaseInKubernetes creates (updates if it exists) the StatefulSet with its Service. +// It returns any errors coming from Kubernetes API. +func DatabaseInKubernetes(client kubernetesClient.Client, mdb mdbv1.MongoDB, sts appsv1.StatefulSet, config func(mdb mdbv1.MongoDB) construct.DatabaseStatefulSetOptions, log *zap.SugaredLogger) error { + opts := config(mdb) + set, err := enterprisests.CreateOrUpdateStatefulset(client, + mdb.Namespace, + log, + &sts, + ) + if err != nil { + return err + } + + namespacedName := kube.ObjectKey(mdb.Namespace, set.Spec.ServiceName) + internalService := buildService(namespacedName, &mdb, &set.Spec.ServiceName, nil, opts.ServicePort, omv1.MongoDBOpsManagerServiceDefinition{Type: corev1.ServiceTypeClusterIP}) + + // Adds Prometheus Port if Prometheus has been enabled. + prom := mdb.GetPrometheus() + if prom != nil { + internalService.Spec.Ports = append(internalService.Spec.Ports, corev1.ServicePort{Port: int32(prom.GetPort()), Name: "prometheus"}) + } + err = service.CreateOrUpdateService(client, internalService) + if err != nil { + return err + } + + for podNum := 0; podNum < mdb.GetSpec().Replicas(); podNum++ { + namespacedName = kube.ObjectKey(mdb.Namespace, dns.GetExternalServiceName(set.Name, podNum)) + if mdb.Spec.ExternalAccessConfiguration == nil { + if err := service.DeleteServiceIfItExists(client, namespacedName); err != nil { + return err + } + continue + } + + if mdb.Spec.ExternalAccessConfiguration != nil { + // we only need an external service for mongos + if err = createExternalServices(client, mdb, opts, namespacedName, set, podNum); err != nil { + return err + } + } + } + + return nil +} + +// createExternalServices creates the external services. The function does not create external services for sharded clusters which given stateful-sets are not mongos. +func createExternalServices(client kubernetesClient.Client, mdb mdbv1.MongoDB, opts construct.DatabaseStatefulSetOptions, namespacedName client.ObjectKey, set *appsv1.StatefulSet, podNum int) error { + if mdb.IsShardedCluster() && !opts.IsMongos() { + return nil + } + externalService := buildService(namespacedName, &mdb, &set.Spec.ServiceName, pointer.String(dns.GetPodName(set.Name, podNum)), opts.ServicePort, omv1.MongoDBOpsManagerServiceDefinition{Type: corev1.ServiceTypeLoadBalancer}) + + if mdb.Spec.DbCommonSpec.GetExternalDomain() != nil { + // When an external domain is defined, we put it into process.hostname in automation config. Because of that we need to define additional well-defined port for backups. + // This backup port is not needed when we use headless service, because then agent is resolving DNS directly to pod's IP and that allows to connect + // to any port in a pod, even ephemeral one. + // When we put any other address than headless service into process.hostname: non-headles service fqdn (e.g. in multi cluster using service mesh) or + // external domain (e.g. for multi-cluster no-mesh), then we need to define backup port. + // In the agent process, we pass -ephemeralPortOffset 1 argument to define, that backup port should be a starndard port+1. + backupPort := GetNonEphemeralBackupPort(opts.ServicePort) + externalService.Spec.Ports = append(externalService.Spec.Ports, corev1.ServicePort{Port: backupPort, TargetPort: intstr.FromInt(int(backupPort)), Name: "backup"}) + } + + if mdb.Spec.DbCommonSpec.ExternalAccessConfiguration.ExternalService.SpecWrapper != nil { + externalService.Spec = merge.ServiceSpec(externalService.Spec, mdb.Spec.DbCommonSpec.ExternalAccessConfiguration.ExternalService.SpecWrapper.Spec) + } + externalService.Annotations = merge.StringToStringMap(externalService.Annotations, mdb.Spec.ExternalAccessConfiguration.ExternalService.Annotations) + + err := service.CreateOrUpdateService(client, externalService) + if err != nil && !apiErrors.IsAlreadyExists(err) { + return xerrors.Errorf("failed to created external service: %s, err: %w", externalService.Name, err) + } + return nil +} + +// AppDBInKubernetes creates or updates the StatefulSet and Service required for the AppDB. +func AppDBInKubernetes(client kubernetesClient.Client, opsManager omv1.MongoDBOpsManager, sts appsv1.StatefulSet, log *zap.SugaredLogger) error { + + set, err := enterprisests.CreateOrUpdateStatefulset(client, + opsManager.Namespace, + log, + &sts, + ) + if err != nil { + return err + } + + namespacedName := kube.ObjectKey(opsManager.Namespace, set.Spec.ServiceName) + internalService := buildService(namespacedName, &opsManager, &set.Spec.ServiceName, nil, opsManager.Spec.AppDB.AdditionalMongodConfig.GetPortOrDefault(), omv1.MongoDBOpsManagerServiceDefinition{Type: corev1.ServiceTypeClusterIP}) + + // Adds Prometheus Port if Prometheus has been enabled. + prom := opsManager.Spec.AppDB.Prometheus + if prom != nil { + internalService.Spec.Ports = append(internalService.Spec.Ports, corev1.ServicePort{Port: int32(prom.GetPort()), Name: "prometheus"}) + } + + return service.CreateOrUpdateService(client, internalService) +} + +// BackupDaemonInKubernetes creates or updates the StatefulSet and Services required. +func BackupDaemonInKubernetes(client kubernetesClient.Client, opsManager omv1.MongoDBOpsManager, sts appsv1.StatefulSet, log *zap.SugaredLogger) (bool, error) { + set, err := enterprisests.CreateOrUpdateStatefulset( + client, + opsManager.Namespace, + log, + &sts, + ) + + if err != nil { + // Check if it is a k8s error or a custom one + if _, ok := err.(enterprisests.StatefulSetCantBeUpdatedError); !ok { + return false, err + } + // In this case, we delete the old Statefulset + log.Debug("Deleting the old backup stateful set and creating a new one") + stsNamespacedName := kube.ObjectKey(opsManager.Namespace, opsManager.BackupStatefulSetName()) + err = client.DeleteStatefulSet(stsNamespacedName) + if err != nil { + return false, xerrors.Errorf("failed while trying to delete previous backup daemon statefulset: %w", err) + } + return true, nil + } + namespacedName := kube.ObjectKey(opsManager.Namespace, set.Spec.ServiceName) + internalService := buildService(namespacedName, &opsManager, &set.Spec.ServiceName, nil, construct.BackupDaemonServicePort, omv1.MongoDBOpsManagerServiceDefinition{Type: corev1.ServiceTypeClusterIP}) + err = service.CreateOrUpdateService(client, internalService) + return false, err +} + +// OpsManagerInKubernetes creates all of the required Kubernetes resources for Ops Manager. +// It creates the StatefulSet and all required services. +func OpsManagerInKubernetes(client kubernetesClient.Client, opsManager omv1.MongoDBOpsManager, sts appsv1.StatefulSet, log *zap.SugaredLogger) error { + set, err := enterprisests.CreateOrUpdateStatefulset(client, + opsManager.Namespace, + log, + &sts, + ) + if err != nil { + return err + } + + _, port := opsManager.GetSchemePort() + + namespacedName := kube.ObjectKey(opsManager.Namespace, set.Spec.ServiceName) + internalService := buildService(namespacedName, &opsManager, &set.Spec.ServiceName, nil, int32(port), omv1.MongoDBOpsManagerServiceDefinition{Type: corev1.ServiceTypeClusterIP}) + // add queryable backup port to service + if opsManager.Spec.Backup.Enabled { + if err := addQueryableBackupPortToService(opsManager, &internalService, internalConnectivityPortName); err != nil { + return err + } + } + + err = service.CreateOrUpdateService(client, internalService) + if err != nil { + return err + } + + namespacedName = kube.ObjectKey(opsManager.Namespace, set.Spec.ServiceName+"-ext") + var externalService *corev1.Service = nil + if opsManager.Spec.MongoDBOpsManagerExternalConnectivity != nil { + svc := buildService(namespacedName, &opsManager, &set.Spec.ServiceName, nil, int32(port), *opsManager.Spec.MongoDBOpsManagerExternalConnectivity) + + svc.Spec.Ports = append(svc.Spec.Ports) + externalService = &svc + } else { + if err := service.DeleteServiceIfItExists(client, namespacedName); err != nil { + return err + } + + } + + // Need to create queryable backup service + if opsManager.Spec.Backup.Enabled { + if opsManager.Spec.MongoDBOpsManagerExternalConnectivity != nil { + if err := addQueryableBackupPortToService(opsManager, externalService, externalConnectivityPortName); err != nil { + return err + } + } + } + + if externalService != nil { + if err := service.CreateOrUpdateService(client, *externalService); err != nil { + return err + } + } + return nil +} + +// addQueryableBackupPortToService adds the backup port to the existing external Ops Manager service. +// this function assumes externalService is not nil. +func addQueryableBackupPortToService(opsManager omv1.MongoDBOpsManager, service *corev1.Service, portName string) error { + backupSvcPort, err := opsManager.Spec.BackupSvcPort() + if err != nil { + return xerrors.Errorf("can't parse queryable backup port: %w", err) + } + service.Spec.Ports[0].Name = portName + service.Spec.Ports = append(service.Spec.Ports, corev1.ServicePort{ + Name: backupPortName, + Port: backupSvcPort, + }) + return nil +} + +// buildService creates the Kube Service. If it should be seen externally it makes it of type NodePort that will assign +// some random port in the range 30000-32767 +// Note that itself service has no dedicated IP by default ("clusterIP: None") as all mongo entities should be directly +// addressable. +// This function will update a Service object if passed, or return a new one if passed nil, this is to be able to update +// Services and to not change any attribute they might already have that needs to be maintained. +// +// When appLabel is specified, then the selector is targeting all pods (round-robin service). Usable for e.g. OpsManager service. +// When podLabel is specified, then the selector is targeting only a single pod. Used for external services or multi-cluster services. +func buildService(namespacedName types.NamespacedName, owner v1.CustomResourceReadWriter, appLabel *string, podLabel *string, port int32, mongoServiceDefinition omv1.MongoDBOpsManagerServiceDefinition) corev1.Service { + labels := map[string]string{ + construct.ControllerLabelName: util.OperatorName, + } + + if appLabel != nil { + labels[appLabelKey] = *appLabel + } + + if podLabel != nil { + labels[podNameLabelKey] = *podLabel + } + + svcBuilder := service.Builder(). + SetNamespace(namespacedName.Namespace). + SetName(namespacedName.Name). + SetOwnerReferences(kube.BaseOwnerReference(owner)). + SetLabels(labels). + SetSelector(labels). + SetServiceType(mongoServiceDefinition.Type). + SetPublishNotReadyAddresses(true) + + serviceType := mongoServiceDefinition.Type + switch serviceType { + case corev1.ServiceTypeNodePort, corev1.ServiceTypeLoadBalancer: + // Service will have a NodePort + svcPort := corev1.ServicePort{TargetPort: intstr.FromInt(int(port)), Name: "mongodb"} + svcPort.NodePort = mongoServiceDefinition.Port + if mongoServiceDefinition.Port != 0 { + svcPort.Port = mongoServiceDefinition.Port + } else { + svcPort.Port = port + } + svcBuilder.AddPort(&svcPort).SetClusterIP("") + case corev1.ServiceTypeClusterIP: + svcBuilder.SetClusterIP("None") + // Service will have a named Port + svcBuilder.AddPort(&corev1.ServicePort{Port: port, Name: "mongodb"}) + default: + // Service will have a regular Port (unnamed) + svcBuilder.AddPort(&corev1.ServicePort{Port: port}) + } + + if mongoServiceDefinition.Annotations != nil { + svcBuilder.SetAnnotations(mongoServiceDefinition.Annotations) + } + + if mongoServiceDefinition.LoadBalancerIP != "" { + svcBuilder.SetLoadBalancerIP(mongoServiceDefinition.LoadBalancerIP) + } + + if mongoServiceDefinition.ExternalTrafficPolicy != "" { + svcBuilder.SetExternalTrafficPolicy(mongoServiceDefinition.ExternalTrafficPolicy) + } + + return svcBuilder.Build() +} + +// GetNonEphemeralBackupPort returns port number that will be used when we instruct the agent to use non-ephemeral port for backup monogod. +// Non-ephemeral ports are used when we set process.hostname for anything other than headless service FQDN. +func GetNonEphemeralBackupPort(mongodPort int32) int32 { + return mongodPort + 1 +} diff --git a/controllers/operator/create/create_test.go b/controllers/operator/create/create_test.go new file mode 100644 index 000000000..4b6b44f52 --- /dev/null +++ b/controllers/operator/create/create_test.go @@ -0,0 +1,335 @@ +package create + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/pointer" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/construct" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/secrets" + "go.uber.org/zap" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + omv1 "github.com/10gen/ops-manager-kubernetes/api/v1/om" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/mock" + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "github.com/10gen/ops-manager-kubernetes/pkg/vault" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +func init() { + mock.InitDefaultEnvVariables() +} + +func TestBuildService(t *testing.T) { + mdb := mdbv1.NewReplicaSetBuilder().Build() + svc := buildService(kube.ObjectKey(mock.TestNamespace, "my-svc"), mdb, pointer.String("label"), nil, 2000, omv1.MongoDBOpsManagerServiceDefinition{ + Type: corev1.ServiceTypeClusterIP, + Port: 2000, + LoadBalancerIP: "loadbalancerip", + }) + + assert.Len(t, svc.OwnerReferences, 1) + assert.Equal(t, mdb.Name, svc.OwnerReferences[0].Name) + assert.Equal(t, mdb.GetObjectKind().GroupVersionKind().Kind, svc.OwnerReferences[0].Kind) + assert.Equal(t, mock.TestNamespace, svc.Namespace) + assert.Equal(t, "my-svc", svc.Name) + assert.Equal(t, "loadbalancerip", svc.Spec.LoadBalancerIP) + assert.Equal(t, "None", svc.Spec.ClusterIP) + assert.Equal(t, int32(2000), svc.Spec.Ports[0].Port) + assert.Equal(t, "label", svc.Labels[appLabelKey]) + assert.NotContains(t, svc.Labels, podNameLabelKey) + assert.True(t, svc.Spec.PublishNotReadyAddresses) + + // test podName label not nil + svc = buildService(kube.ObjectKey(mock.TestNamespace, "my-svc"), mdb, nil, pointer.String("podName"), 2000, omv1.MongoDBOpsManagerServiceDefinition{ + Type: corev1.ServiceTypeClusterIP, + Port: 2000, + LoadBalancerIP: "loadbalancerip", + }) + + assert.Len(t, svc.OwnerReferences, 1) + assert.Equal(t, mdb.Name, svc.OwnerReferences[0].Name) + assert.Equal(t, mdb.GetObjectKind().GroupVersionKind().Kind, svc.OwnerReferences[0].Kind) + assert.Equal(t, mock.TestNamespace, svc.Namespace) + assert.Equal(t, "my-svc", svc.Name) + assert.Equal(t, "loadbalancerip", svc.Spec.LoadBalancerIP) + assert.Equal(t, "None", svc.Spec.ClusterIP) + assert.Equal(t, int32(2000), svc.Spec.Ports[0].Port) + assert.NotContains(t, svc.Labels, appLabelKey) + assert.Equal(t, "podName", svc.Labels[podNameLabelKey]) + assert.True(t, svc.Spec.PublishNotReadyAddresses) +} + +func TestBackupServiceCreated_NoExternalConnectivity(t *testing.T) { + testOm := omv1.NewOpsManagerBuilderDefault(). + SetName("test-om"). + SetAppDBPassword("my-secret", "password").SetBackup(omv1.MongoDBOpsManagerBackup{ + Enabled: true, + }).AddConfiguration("brs.queryable.proxyPort", "1234"). + Build() + + client := mock.NewClient() + secretsClient := secrets.SecretClient{ + VaultClient: &vault.VaultClient{}, + KubeClient: client, + } + sts, err := construct.OpsManagerStatefulSet(secretsClient, testOm, zap.S()) + assert.NoError(t, err) + + err = OpsManagerInKubernetes(client, testOm, sts, zap.S()) + assert.NoError(t, err) + + _, err = client.GetService(kube.ObjectKey(testOm.Namespace, testOm.SvcName()+"-ext")) + assert.Error(t, err, "No external service should have been created") + + svc, err := client.GetService(kube.ObjectKey(testOm.Namespace, testOm.SvcName())) + assert.NoError(t, err, "Internal service exists") + + assert.Len(t, svc.Spec.Ports, 2, "Backup Service should have been added to existing external service") + + port0 := svc.Spec.Ports[0] + assert.Equal(t, internalConnectivityPortName, port0.Name) + + port1 := svc.Spec.Ports[1] + assert.Equal(t, backupPortName, port1.Name) + assert.Equal(t, int32(1234), port1.Port) + +} + +func TestBackupServiceCreated_ExternalConnectivity(t *testing.T) { + testOm := omv1.NewOpsManagerBuilderDefault(). + SetName("test-om"). + SetAppDBPassword("my-secret", "password"). + SetBackup(omv1.MongoDBOpsManagerBackup{ + Enabled: true, + }).AddConfiguration("brs.queryable.proxyPort", "1234"). + SetExternalConnectivity(omv1.MongoDBOpsManagerServiceDefinition{ + Type: corev1.ServiceTypeNodePort, + Port: 5000, + }). + Build() + client := mock.NewClient() + secretsClient := secrets.SecretClient{ + VaultClient: &vault.VaultClient{}, + KubeClient: client, + } + sts, err := construct.OpsManagerStatefulSet(secretsClient, testOm, zap.S()) + assert.NoError(t, err) + + err = OpsManagerInKubernetes(client, testOm, sts, zap.S()) + assert.NoError(t, err) + + externalService, err := client.GetService(kube.ObjectKey(testOm.Namespace, testOm.SvcName()+"-ext")) + assert.NoError(t, err, "An External service should have been created") + + assert.Len(t, externalService.Spec.Ports, 2, "Backup Service should have been added to existing external service") + + port0 := externalService.Spec.Ports[0] + assert.Equal(t, externalConnectivityPortName, port0.Name) + assert.Equal(t, int32(5000), port0.Port) + assert.Equal(t, intstr.FromInt(8080), port0.TargetPort) + assert.Equal(t, int32(5000), port0.NodePort) + + port1 := externalService.Spec.Ports[1] + assert.Equal(t, backupPortName, port1.Name) + assert.Equal(t, int32(1234), port1.Port) +} + +func TestDatabaseInKubernetes_ExternalServicesWithoutExternalDomain(t *testing.T) { + svc := corev1.Service{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{Name: "mdb-0-svc-external"}, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "mongodb", + TargetPort: intstr.IntOrString{IntVal: 27017}, + }, + }, + Type: corev1.ServiceTypeLoadBalancer, + PublishNotReadyAddresses: true, + }, + } + + service1 := svc + service1.Name = "mdb-0-svc-external" + service2 := svc + service2.Name = "mdb-1-svc-external" + expectedServices := []corev1.Service{service1, service2} + + testDatabaseInKubernetesExternalServices(t, mdbv1.ExternalAccessConfiguration{}, expectedServices) +} + +func TestDatabaseInKubernetes_ExternalServicesWithExternalDomainHaveAdditionalBackupPort(t *testing.T) { + svc := corev1.Service{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{Name: "mdb-0-svc-external"}, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "mongodb", + TargetPort: intstr.IntOrString{IntVal: 27017}, + }, + { + Name: "backup", + TargetPort: intstr.IntOrString{IntVal: 27018}, + }, + }, + Type: corev1.ServiceTypeLoadBalancer, + PublishNotReadyAddresses: true, + }, + } + + service1 := svc + service1.Name = "mdb-0-svc-external" + service2 := svc + service2.Name = "mdb-1-svc-external" + expectedServices := []corev1.Service{service1, service2} + + testDatabaseInKubernetesExternalServices(t, mdbv1.ExternalAccessConfiguration{ExternalDomain: pointer.String("example.com")}, expectedServices) +} + +func TestDatabaseInKubernetes_ExternalServicesWithServiceSpecOverrides(t *testing.T) { + svc := corev1.Service{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{Name: "mdb-0-svc-external", Annotations: map[string]string{ + "key": "value", + }}, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "mongodb", + TargetPort: intstr.IntOrString{IntVal: 27017}, + }, + { + Name: "backup", + TargetPort: intstr.IntOrString{IntVal: 27018}, + }, + }, + Type: corev1.ServiceTypeNodePort, + PublishNotReadyAddresses: true, + }, + } + + service1 := svc + service1.Name = "mdb-0-svc-external" + service2 := svc + service2.Name = "mdb-1-svc-external" + expectedServices := []corev1.Service{service1, service2} + + externalAccessConfiguration := mdbv1.ExternalAccessConfiguration{ + ExternalDomain: pointer.String("example.com"), + ExternalService: mdbv1.ExternalServiceConfiguration{ + SpecWrapper: &mdbv1.ServiceSpecWrapper{Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + }}, + Annotations: map[string]string{ + "key": "value", + }, + }, + } + testDatabaseInKubernetesExternalServices(t, externalAccessConfiguration, expectedServices) +} + +func testDatabaseInKubernetesExternalServices(t *testing.T, externalAccessConfiguration mdbv1.ExternalAccessConfiguration, expectedServices []corev1.Service) { + log := zap.S() + manager := mock.NewEmptyManager() + manager.Client.AddDefaultMdbConfigResources() + + mdb := mdbv1.NewReplicaSetBuilder(). + SetName("mdb"). + SetNamespace("my-namespace"). + SetMembers(2). + Build() + mdb.Spec.ExternalAccessConfiguration = &externalAccessConfiguration + + sts := construct.DatabaseStatefulSet(*mdb, construct.ReplicaSetOptions(construct.GetPodEnvOptions()), log) + err := DatabaseInKubernetes(manager.Client, *mdb, sts, construct.ReplicaSetOptions(), log) + assert.NoError(t, err) + + // we only test a subset of fields from service spec, which are the most relevant for external services + for _, expectedService := range expectedServices { + actualService, err := manager.Client.GetService(types.NamespacedName{Name: fmt.Sprintf(expectedService.GetName()), Namespace: "my-namespace"}) + require.NoError(t, err, "serviceName: %s", expectedService.GetName()) + require.NotNil(t, actualService) + require.Len(t, actualService.Spec.Ports, len(expectedService.Spec.Ports)) + for i, expectedPort := range expectedService.Spec.Ports { + actualPort := actualService.Spec.Ports[i] + assert.Equal(t, expectedPort.Name, actualPort.Name) + assert.Equal(t, expectedPort.TargetPort.IntVal, actualPort.TargetPort.IntVal) + } + assert.Equal(t, expectedService.Spec.Type, actualService.Spec.Type) + assert.True(t, expectedService.Spec.PublishNotReadyAddresses, actualService.Spec.PublishNotReadyAddresses) + if expectedService.Annotations != nil { + assert.Equal(t, expectedService.Annotations, actualService.Annotations) + } + } + + // disable external access -> remove external services + mdb.Spec.ExternalAccessConfiguration = nil + err = DatabaseInKubernetes(manager.Client, *mdb, sts, construct.ReplicaSetOptions(), log) + assert.NoError(t, err) + + for _, expectedService := range expectedServices { + _, err := manager.Client.GetService(types.NamespacedName{Name: fmt.Sprintf(expectedService.GetName()), Namespace: "my-namespace"}) + assert.True(t, errors.IsNotFound(err)) + } +} + +func TestDatabaseInKubernetesExternalServicesSharded(t *testing.T) { + log := zap.S() + manager := mock.NewEmptyManager() + manager.Client.AddDefaultMdbConfigResources() + + mdb := mdbv1.NewDefaultShardedClusterBuilder(). + SetName("mdb"). + SetNamespace("my-namespace"). + SetMongosCountSpec(2). + SetShardCountSpec(1). + SetConfigServerCountSpec(1). + Build() + + mdb.Spec.ExternalAccessConfiguration = &mdbv1.ExternalAccessConfiguration{} + + err := createShardSts(t, mdb, log, manager) + require.NoError(t, err) + + err = createMongosSts(t, mdb, log, manager) + require.NoError(t, err) + + actualService, err := manager.Client.GetService(types.NamespacedName{Name: fmt.Sprintf("mdb-mongos-0-svc-external"), Namespace: "my-namespace"}) + require.NoError(t, err) + require.NotNil(t, actualService) + + actualService, err = manager.Client.GetService(types.NamespacedName{Name: fmt.Sprintf("mdb-mongos-1-svc-external"), Namespace: "my-namespace"}) + require.NoError(t, err) + require.NotNil(t, actualService) + + _, err = manager.Client.GetService(types.NamespacedName{Name: fmt.Sprintf("mdb-config-0-svc-external"), Namespace: "my-namespace"}) + require.Errorf(t, err, "expected no config service") + + _, err = manager.Client.GetService(types.NamespacedName{Name: fmt.Sprintf("mdb-0-svc-external"), Namespace: "my-namespace"}) + require.Errorf(t, err, "expected no shard service") + +} + +func createShardSts(t *testing.T, mdb *mdbv1.MongoDB, log *zap.SugaredLogger, manager *mock.MockedManager) error { + sts := construct.DatabaseStatefulSet(*mdb, construct.ShardOptions(1, construct.GetPodEnvOptions()), log) + err := DatabaseInKubernetes(manager.Client, *mdb, sts, construct.ShardOptions(1), log) + assert.NoError(t, err) + return err +} +func createMongosSts(t *testing.T, mdb *mdbv1.MongoDB, log *zap.SugaredLogger, manager *mock.MockedManager) error { + sts := construct.DatabaseStatefulSet(*mdb, construct.MongosOptions(construct.GetPodEnvOptions()), log) + err := DatabaseInKubernetes(manager.Client, *mdb, sts, construct.MongosOptions(), log) + assert.NoError(t, err) + return err +} diff --git a/controllers/operator/database_statefulset_options.go b/controllers/operator/database_statefulset_options.go new file mode 100644 index 000000000..a466d6e9b --- /dev/null +++ b/controllers/operator/database_statefulset_options.go @@ -0,0 +1,62 @@ +package operator + +import ( + "github.com/10gen/ops-manager-kubernetes/controllers/operator/construct" + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" + "github.com/10gen/ops-manager-kubernetes/pkg/vault" +) + +// CurrentAgentAuthMechanism will assign the given value as the current authentication mechanism. +func CurrentAgentAuthMechanism(mode string) func(options *construct.DatabaseStatefulSetOptions) { + return func(options *construct.DatabaseStatefulSetOptions) { + options.CurrentAgentAuthMode = mode + } +} + +// PodEnvVars will assign the given env vars which will used during StatefulSet construction. +func PodEnvVars(vars *env.PodEnvVars) func(options *construct.DatabaseStatefulSetOptions) { + return func(options *construct.DatabaseStatefulSetOptions) { + options.PodVars = vars + } +} + +// Replicas will set the given number of replicas when building a StatefulSet. +func Replicas(replicas int) func(options *construct.DatabaseStatefulSetOptions) { + return func(options *construct.DatabaseStatefulSetOptions) { + options.Replicas = replicas + } +} + +// CertificateHash will assign the given CertificateHash during StatefulSet construction. +func CertificateHash(hash string) func(options *construct.DatabaseStatefulSetOptions) { + return func(options *construct.DatabaseStatefulSetOptions) { + options.CertificateHash = hash + } +} + +// InternalClusterHash will assign the given InternalClusterHash during StatefulSet construction. +func InternalClusterHash(hash string) func(options *construct.DatabaseStatefulSetOptions) { + return func(options *construct.DatabaseStatefulSetOptions) { + options.InternalClusterHash = hash + } +} + +func PrometheusTLSCertHash(hash string) func(options *construct.DatabaseStatefulSetOptions) { + return func(options *construct.DatabaseStatefulSetOptions) { + options.PrometheusTLSCertHash = hash + } +} + +// WithLabels will assing the provided labels during the statefulset construction +func WithLabels(labels map[string]string) func(options *construct.DatabaseStatefulSetOptions) { + return func(options *construct.DatabaseStatefulSetOptions) { + options.Labels = labels + } +} + +// WithVaultConfig sets the vault configuration to extract annotations for the statefulset. +func WithVaultConfig(config vault.VaultConfiguration) func(options *construct.DatabaseStatefulSetOptions) { + return func(options *construct.DatabaseStatefulSetOptions) { + options.VaultConfig = config + } +} diff --git a/controllers/operator/inspect/statefulset_inspector.go b/controllers/operator/inspect/statefulset_inspector.go new file mode 100644 index 000000000..73d3f7518 --- /dev/null +++ b/controllers/operator/inspect/statefulset_inspector.go @@ -0,0 +1,63 @@ +package inspect + +import ( + "fmt" + + "go.uber.org/zap" + + "github.com/10gen/ops-manager-kubernetes/api/v1/status" + appsv1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// StatefulSetState is an entity encapsulating all the information about StatefulSet state +type StatefulSetState struct { + statefulSetKey client.ObjectKey + updated int32 + ready int32 + total int32 + generation int64 + observedGeneration int64 + updateStrategyType appsv1.StatefulSetUpdateStrategyType +} + +// GetResourcesNotReadyStatus returns the status of dependent resources which have any problems +func (s StatefulSetState) GetResourcesNotReadyStatus() []status.ResourceNotReady { + if s.IsReady() { + return []status.ResourceNotReady{} + } + zap.S().Debugf("StatefulSet %s (total: %d, ready: %d, updated: %d, generation: %d, observedGeneration: %d)", s.statefulSetKey.Name, s.total, s.ready, s.updated, s.generation, s.observedGeneration) + msg := fmt.Sprintf("Not all the Pods are ready (total: %d, updated: %d, ready: %d)", s.total, s.updated, s.ready) + return []status.ResourceNotReady{{ + Kind: status.StatefulsetKind, + Name: s.statefulSetKey.Name, + Message: msg, + }} +} + +// GetMessage returns the general message to be shown in status or/and printed in logs +func (s StatefulSetState) GetMessage() string { + if s.IsReady() { + return fmt.Sprintf("StatefulSet %s is ready", s.statefulSetKey) + } + return fmt.Sprintf("StatefulSet not ready") +} + +func (s StatefulSetState) IsReady() bool { + isReady := s.updated == s.ready && s.ready == s.total && s.observedGeneration == s.generation + return isReady || s.updateStrategyType == appsv1.OnDeleteStatefulSetStrategyType +} + +func StatefulSet(set appsv1.StatefulSet) StatefulSetState { + state := StatefulSetState{ + statefulSetKey: types.NamespacedName{Namespace: set.Namespace, Name: set.Name}, + updated: set.Status.UpdatedReplicas, + ready: set.Status.ReadyReplicas, + total: *set.Spec.Replicas, + observedGeneration: set.Status.ObservedGeneration, + generation: set.Generation, + updateStrategyType: set.Spec.UpdateStrategy.Type, + } + return state +} diff --git a/controllers/operator/inspect/statefulset_inspector_test.go b/controllers/operator/inspect/statefulset_inspector_test.go new file mode 100644 index 000000000..86f3f013a --- /dev/null +++ b/controllers/operator/inspect/statefulset_inspector_test.go @@ -0,0 +1,48 @@ +package inspect + +import ( + "testing" + + "github.com/10gen/ops-manager-kubernetes/api/v1/status" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + + "github.com/stretchr/testify/assert" + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestStatefulSetInspector(t *testing.T) { + + statefulSet := appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Replicas: util.Int32Ref(3), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "sts", + Namespace: "ns", + Generation: 1, + }, + Status: appsv1.StatefulSetStatus{ + Replicas: 3, + ReadyReplicas: 1, + UpdatedReplicas: 2, + }, + } + + state := StatefulSet(statefulSet) + assert.False(t, state.IsReady()) + assert.Len(t, state.GetResourcesNotReadyStatus(), 1) + assert.Contains(t, state.GetResourcesNotReadyStatus()[0].Message, "Not all the Pods are ready") + assert.Equal(t, state.GetResourcesNotReadyStatus()[0].Kind, status.StatefulsetKind) + assert.Equal(t, state.GetResourcesNotReadyStatus()[0].Name, "sts") + + // StatefulSet "got" to ready state + statefulSet.Status.UpdatedReplicas = 3 + statefulSet.Status.ReadyReplicas = 3 + statefulSet.Status.ObservedGeneration = 1 + + state = StatefulSet(statefulSet) + assert.True(t, state.IsReady()) + assert.Len(t, state.GetResourcesNotReadyStatus(), 0) + +} diff --git a/controllers/operator/ldap/ldap_types.go b/controllers/operator/ldap/ldap_types.go new file mode 100644 index 000000000..182a301e0 --- /dev/null +++ b/controllers/operator/ldap/ldap_types.go @@ -0,0 +1,21 @@ +package ldap + +// Ldap holds all the fields required to configure LDAP authentication +type Ldap struct { + AuthzQueryTemplate string `json:"authzQueryTemplate,omitempty"` + BindMethod string `json:"bindMethod"` + BindQueryUser string `json:"bindQueryUser"` + BindSaslMechanisms string `json:"bindSaslMechanisms,omitempty"` + Servers string `json:"servers"` + TransportSecurity string `json:"transportSecurity"` + UserToDnMapping string `json:"userToDNMapping,omitempty"` + ValidateLDAPServerConfig bool `json:"validateLDAPServerConfig"` + BindQueryPassword string `json:"bindQueryPassword"` + TimeoutMS int `json:"timeoutMS,omitempty"` + UserCacheInvalidationInterval int `json:"userCacheInvalidationInterval,omitempty"` + + // Uses an undocumented property from the API used to mount + // a CA file. This is done by the automation agent. + // https://mongodb.slack.com/archives/CN0JB7XT2/p1594229779090700 + CaFileContents string `json:"CAFileContents"` +} diff --git a/controllers/operator/mock/mockedkubeclient.go b/controllers/operator/mock/mockedkubeclient.go new file mode 100644 index 000000000..5dbbf4b05 --- /dev/null +++ b/controllers/operator/mock/mockedkubeclient.go @@ -0,0 +1,805 @@ +package mock + +import ( + "context" + "encoding/json" + "net/http" + "runtime" + "testing" + "time" + + "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "golang.org/x/xerrors" + + "github.com/go-logr/logr" + "github.com/hashicorp/go-multierror" + "k8s.io/apimachinery/pkg/util/validation" + + kubernetesClient "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/client" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/configmap" + + jsonpatch "github.com/evanphx/json-patch" + "k8s.io/apimachinery/pkg/types" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/config/v1alpha1" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/10gen/ops-manager-kubernetes/controllers/om" + + "github.com/10gen/ops-manager-kubernetes/pkg/dns" + "github.com/10gen/ops-manager-kubernetes/pkg/handler" + "github.com/10gen/ops-manager-kubernetes/pkg/multicluster" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + + "github.com/stretchr/testify/assert" + + "reflect" + + "fmt" + + appsv1 "k8s.io/api/apps/v1" + certsv1 "k8s.io/api/certificates/v1beta1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apiruntime "k8s.io/apimachinery/pkg/runtime" +) + +// todo rename the file to client_test.go later + +const ( + TestProjectConfigMapName = om.TestGroupName + TestCredentialsSecretName = "my-credentials" + TestNamespace = "my-namespace" + TestMongoDBName = "my-mongodb" +) + +type MockedConfigMapClient struct { + client client.Client +} + +// GetConfigMap provides a thin wrapper and client.client to access corev1.ConfigMap types +func (c *MockedConfigMapClient) GetConfigMap(objectKey client.ObjectKey) (corev1.ConfigMap, error) { + cm := corev1.ConfigMap{} + if err := c.client.Get(context.TODO(), objectKey, &cm); err != nil { + return corev1.ConfigMap{}, err + } + return cm, nil +} + +// UpdateConfigMap provides a thin wrapper and client.Client to update corev1.ConfigMap types +func (c *MockedConfigMapClient) UpdateConfigMap(cm corev1.ConfigMap) error { + if err := c.client.Update(context.TODO(), &cm); err != nil { + return err + } + return nil +} + +// CreateConfigMap provides a thin wrapper and client.Client to create corev1.ConfigMap types +func (c *MockedConfigMapClient) CreateConfigMap(cm corev1.ConfigMap) error { + if err := c.client.Create(context.TODO(), &cm); err != nil { + return err + } + return nil +} + +func (m *MockedConfigMapClient) DeleteConfigMap(key client.ObjectKey) error { + cm := corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: key.Name, + Namespace: key.Namespace, + }, + } + if err := m.client.Delete(context.TODO(), &cm); err != nil { + return err + } + return nil +} + +type MockedSecretClient struct { + client client.Client +} + +// GetSecret provides a thin wrapper and client.Client to access corev1.Secret types +func (c *MockedSecretClient) GetSecret(objectKey client.ObjectKey) (corev1.Secret, error) { + s := corev1.Secret{} + if err := c.client.Get(context.TODO(), objectKey, &s); err != nil { + return corev1.Secret{}, err + } + return s, nil +} + +// UpdateSecret provides a thin wrapper and client.Client to update corev1.Secret types +func (c *MockedSecretClient) UpdateSecret(secret corev1.Secret) error { + if err := c.client.Update(context.TODO(), &secret); err != nil { + return err + } + return nil +} + +// CreateSecret provides a thin wrapper and client.Client to create corev1.Secret types +func (c *MockedSecretClient) CreateSecret(secret corev1.Secret) error { + if err := c.client.Create(context.TODO(), &secret); err != nil { + return err + } + return nil +} + +// DeleteSecret provides a thin wrapper and client.Client to delete corev1.Secret types +func (c *MockedSecretClient) DeleteSecret(key client.ObjectKey) error { + s := corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: key.Name, + Namespace: key.Namespace, + }, + } + if err := c.client.Delete(context.TODO(), &s); err != nil { + return err + } + return nil +} + +type MockedServiceClient struct { + client client.Client +} + +// GetService provides a thin wrapper and client.Client to access corev1.Service types +func (c *MockedServiceClient) GetService(objectKey client.ObjectKey) (corev1.Service, error) { + s := corev1.Service{} + if err := c.client.Get(context.TODO(), objectKey, &s); err != nil { + return corev1.Service{}, err + } + return s, nil +} + +// UpdateService provides a thin wrapper and client.Client to update corev1.Service types +func (c *MockedServiceClient) UpdateService(secret corev1.Service) error { + if err := c.client.Update(context.TODO(), &secret); err != nil { + return err + } + return nil +} + +// CreateService provides a thin wrapper and client.Client to create corev1.Service types +func (c *MockedServiceClient) CreateService(s corev1.Service) error { + if err := c.client.Create(context.TODO(), &s); err != nil { + return err + } + return nil +} + +// DeleteService provides a thin wrapper and client.Client to delete corev1.Service types +func (c *MockedSecretClient) DeleteService(key client.ObjectKey) error { + s := corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: key.Name, + Namespace: key.Namespace, + }, + } + if err := c.client.Delete(context.TODO(), &s); err != nil { + return err + } + return nil +} + +func (c *MockedSecretClient) ReadSecret(secretName types.NamespacedName, basePath string) (map[string]string, error) { + return map[string]string{}, &errors.StatusError{ErrStatus: metav1.Status{Reason: metav1.StatusReasonNotFound}} +} + +type MockedStatefulSetClient struct { + client client.Client +} + +// GetService provides a thin wrapper and client.Client to access appsv1.StatefulSet types +func (c *MockedStatefulSetClient) GetStatefulSet(objectKey client.ObjectKey) (appsv1.StatefulSet, error) { + sts := appsv1.StatefulSet{} + if err := c.client.Get(context.TODO(), objectKey, &sts); err != nil { + return appsv1.StatefulSet{}, err + } + return sts, nil +} + +// GetPod provides a thin wrapper and client.Client to access corev1.Pod types +func (c *MockedStatefulSetClient) GetPod(objectKey client.ObjectKey) (corev1.Pod, error) { + pod := corev1.Pod{} + if err := c.client.Get(context.TODO(), objectKey, &pod); err != nil { + return corev1.Pod{}, err + } + return pod, nil +} + +// UpdateStatefulSet provides a thin wrapper and client.Client to update appsv1.StatefulSet types +func (c *MockedStatefulSetClient) UpdateStatefulSet(sts appsv1.StatefulSet) (appsv1.StatefulSet, error) { + updatesSts := sts + if err := c.client.Update(context.TODO(), &updatesSts); err != nil { + return appsv1.StatefulSet{}, err + } + return updatesSts, nil +} + +// CreateStatefulSet provides a thin wrapper and client.Client to create appsv1.StatefulSet types +func (c *MockedStatefulSetClient) CreateStatefulSet(sts appsv1.StatefulSet) error { + if err := c.client.Create(context.TODO(), &sts); err != nil { + return err + } + return nil +} + +// DeleteStatefulSet provides a thin wrapper and client.Client to delete appsv1.StatefulSet types +func (c *MockedStatefulSetClient) DeleteStatefulSet(key client.ObjectKey) error { + sts := appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: key.Name, + Namespace: key.Namespace, + }, + } + if err := c.client.Delete(context.TODO(), &sts); err != nil { + return err + } + return nil +} + +// MockedClient is the mocked implementation of client.Client from controller-runtime library +type MockedClient struct { + *MockedConfigMapClient + *MockedSecretClient + *MockedServiceClient + *MockedStatefulSetClient + + // backingMap contains all of the maps of all apiruntime.Objects. Using the GetMapForObject + // function will dynamically initialize a new map for the type in question + backingMap map[reflect.Type]map[client.ObjectKey]apiruntime.Object + + // mocked client keeps track of all implemented functions called - uses reflection Func for this to enable type-safety + // and make function names rename easier + history []*HistoryItem + + // if the StatefulSet created must be marked ready right after creation + markStsReady bool + UpdateFunc func(ctx context.Context, obj apiruntime.Object) error +} + +var _ kubernetesClient.Client = &MockedClient{} + +func NewClient() *MockedClient { + api := MockedClient{} + api.MockedConfigMapClient = &MockedConfigMapClient{client: &api} + api.MockedSecretClient = &MockedSecretClient{client: &api} + api.MockedServiceClient = &MockedServiceClient{client: &api} + api.MockedStatefulSetClient = &MockedStatefulSetClient{client: &api} + + api.backingMap = map[reflect.Type]map[client.ObjectKey]apiruntime.Object{} + + // mark StatefulSet ready right away by default + api.markStsReady = true + + // ugly but seems the only way to clean om global variable for current connection (as golang doesnt' have setup()/teardown() + // methods for testing + om.CurrMockedConnection = nil + + return &api +} + +func (m *MockedClient) RESTMapper() meta.RESTMapper { + return nil +} + +func (m *MockedClient) Scheme() *apiruntime.Scheme { + return nil +} + +func (m *MockedClient) WithResource(object client.Object) *MockedClient { + err := m.Create(context.TODO(), object.(client.Object)) + if err != nil { + // panicking here instead of adding to return type as this function + // is used to initialize the mocked client, with this we can ensure we never + // start in a situation with a resource that has a naming violation. + panic(err) + } + return m +} + +func (m *MockedClient) AddProjectConfigMap(projectName, organizationId string) *MockedClient { + cm := configmap.Builder(). + SetName(TestProjectConfigMapName). + SetNamespace(TestNamespace). + SetDataField(util.OmBaseUrl, "http://mycompany.com:8080"). + SetDataField(util.OmProjectName, projectName). + SetDataField(util.OmOrgId, organizationId). + Build() + + err := m.Create(context.TODO(), &cm) + if err != nil { + panic(err) + } + return m +} + +// AddCredentialsSecret creates the Secret that stores Ops Manager credentials for the test environment. +func (m *MockedClient) AddCredentialsSecret(publicKey, privateKey string) *MockedClient { + stringData := map[string]string{util.OmPublicApiKey: publicKey, util.OmPrivateKey: privateKey} + data := map[string][]byte{} + for s, s2 := range stringData { + data[s] = []byte(s2) + } + credentials := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: TestCredentialsSecretName, Namespace: TestNamespace}, + // we are using Data and not SecretData because our internal secret.Builder only writes information into + // secret.Data not secret.StringData. + Data: data} + err := m.Create(context.TODO(), credentials) + if err != nil { + panic(err) + } + return m +} + +func (m *MockedClient) WithStsReady(ready bool) *MockedClient { + m.markStsReady = ready + return m +} + +func (m *MockedClient) AddDefaultMdbConfigResources() *MockedClient { + m = m.AddProjectConfigMap(om.TestGroupName, "") + return m.AddCredentialsSecret(om.TestUser, om.TestApiKey) +} + +// Get retrieves an obj for the given object key from the Kubernetes Cluster. +// obj must be a struct pointer so that obj can be updated with the response +// returned by the Server. +func (k *MockedClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object) (e error) { + resMap := k.GetMapForObject(obj) + k.addToHistory(reflect.ValueOf(k.Get), obj) + if _, exists := resMap[key]; !exists { + return &errors.StatusError{ErrStatus: metav1.Status{Reason: metav1.StatusReasonNotFound}} + } + // Golang cannot update pointers if they are declared as interfaces... Have to use reflection + v := reflect.ValueOf(obj).Elem() + v.Set(reflect.ValueOf(resMap[key]).Elem()) + return nil +} + +func (k *MockedClient) ApproveAllCSRs() { + for _, csrObject := range k.GetMapForObject(&certsv1.CertificateSigningRequest{}) { + csr := csrObject.(*certsv1.CertificateSigningRequest) + approvedCondition := certsv1.CertificateSigningRequestCondition{ + Type: certsv1.CertificateApproved, + } + csr.Status.Conditions = append(csr.Status.Conditions, approvedCondition) + if err := k.Update(context.Background(), csr); err != nil { + panic(err) + } + } +} + +// List retrieves list of objects for a given namespace and list options. On a +// successful call, Items field in the list will be populated with the +// result returned from the server. +func (k *MockedClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + switch l := list.(type) { + case *corev1.ServiceList: + serviceMap := k.GetMapForObject(&corev1.Service{}) + var services []corev1.Service + for _, v := range serviceMap { + services = append(services, *v.(*corev1.Service)) + } + l.Items = services + return nil + case *appsv1.StatefulSetList: + statefulSetMap := k.GetMapForObject(&appsv1.StatefulSet{}) + var statefulSets []appsv1.StatefulSet + for _, v := range statefulSetMap { + statefulSets = append(statefulSets, *v.(*appsv1.StatefulSet)) + } + l.Items = statefulSets + return nil + case *corev1.ConfigMapList: + configMapMap := k.GetMapForObject(&corev1.ConfigMap{}) + var configMaps []corev1.ConfigMap + for _, v := range configMapMap { + configMaps = append(configMaps, *v.(*corev1.ConfigMap)) + } + l.Items = configMaps + return nil + case *corev1.SecretList: + secretList := k.GetMapForObject(&corev1.Secret{}) + var secrets []corev1.Secret + for _, v := range secretList { + secrets = append(secrets, *v.(*corev1.Secret)) + } + l.Items = secrets + return nil + case *mdb.MongoDBList: + mdbList := k.GetMapForObject(&mdb.MongoDB{}) + var mdbs []mdb.MongoDB + for _, v := range mdbList { + mdbs = append(mdbs, *v.(*mdb.MongoDB)) + } + l.Items = mdbs + return nil + } + return xerrors.Errorf("the List method is not implemented for type %s", reflect.TypeOf(list)) +} + +// Create saves the object obj in the Kubernetes cluster. +func (k *MockedClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error { + obj = obj.DeepCopyObject().(client.Object) + key := ObjectKeyFromApiObject(obj) + resMap := k.GetMapForObject(obj) + + if err := validateDNS1123Subdomain(obj); err != nil { + return err + } + + k.addToHistory(reflect.ValueOf(k.Create), obj) + + if err := k.Get(ctx, key, obj); err == nil { + return xerrors.Errorf("%T %s already exists!", obj, key) + } + + resMap[key] = obj + + switch v := obj.(type) { + case *appsv1.StatefulSet: + k.onStatefulsetUpdate(v) + } + + return nil +} + +// Update updates the given obj in the Kubernetes cluster. obj must be a +// struct pointer so that obj can be updated with the content returned by the Server. +func (k *MockedClient) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error { + if err := validateDNS1123Subdomain(obj); err != nil { + return err + } + obj = obj.DeepCopyObject().(client.Object) + k.addToHistory(reflect.ValueOf(k.Update), obj) + if k.UpdateFunc != nil { + return k.UpdateFunc(ctx, obj) + } + return k.doUpdate(ctx, obj) +} + +func (k *MockedClient) doUpdate(ctx context.Context, obj client.Object) error { + key := ObjectKeyFromApiObject(obj) + + resMap := k.GetMapForObject(obj) + resMap[key] = obj + + switch v := obj.(type) { + case *appsv1.StatefulSet: + k.onStatefulsetUpdate(v) + } + return nil +} + +// Delete deletes the given obj from Kubernetes cluster. +func (k *MockedClient) Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error { + k.addToHistory(reflect.ValueOf(k.Delete), obj) + + key := ObjectKeyFromApiObject(obj) + + resMap := k.GetMapForObject(obj) + delete(resMap, key) + + return nil +} + +func (k *MockedClient) DeleteAllOf(ctx context.Context, obj client.Object, opts ...client.DeleteAllOfOption) error { + k.backingMap[reflect.TypeOf(obj)] = map[client.ObjectKey]apiruntime.Object{} + return nil +} + +func (k *MockedClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + // Finding the object to patch + resMap := k.GetMapForObject(obj) + k.addToHistory(reflect.ValueOf(k.Patch), obj) + key := ObjectKeyFromApiObject(obj) + if _, exists := resMap[key]; !exists { + return &errors.StatusError{ErrStatus: metav1.Status{Reason: metav1.StatusReasonNotFound}} + } + targetObject := resMap[key] + + // Performing patch (serializing to bytes and then deserializing the result back) + patchBytes, err := patch.Data(nil) + if err != nil { + return err + } + var jsonPatch jsonpatch.Patch + jsonPatch, err = jsonpatch.DecodePatch(patchBytes) + if err != nil { + return err + } + + var jsonObject []byte + jsonObject, err = json.Marshal(targetObject) + if err != nil { + return err + } + jsonObject, err = jsonPatch.Apply(jsonObject) + if err != nil { + return err + } + + newObject := obj.DeepCopyObject() + if err = json.Unmarshal(jsonObject, newObject); err != nil { + return err + } + resMap[key] = newObject + + return nil +} + +func (k *MockedClient) Status() client.StatusWriter { + // MockedClient also implements StatusWriter and the Update function does what we need + k.addToHistory(reflect.ValueOf(k.Status), nil) + return k +} + +// Not used in enterprise, these only exist in community. +func (k *MockedClient) GetAndUpdate(nsName types.NamespacedName, obj client.Object, updateFunc func()) error { + return nil +} + +// Not used in enterprise, these only exist in community. +func (k *MockedClient) CreateOrUpdate(obj apiruntime.Object) error { + return nil +} + +// onStatefulsetUpdate emulates statefulsets reaching their desired state, also OM automation agents get "registered" +func (k *MockedClient) onStatefulsetUpdate(set *appsv1.StatefulSet) { + if k.markStsReady { + markStatefulSetsReady(set) + } +} + +func markStatefulSetsReady(set *appsv1.StatefulSet) { + set.Status.UpdatedReplicas = *set.Spec.Replicas + set.Status.ReadyReplicas = *set.Spec.Replicas + + if om.CurrMockedConnection != nil { + // For tests with external domains we set hostnames externally in test, + // as we don't have ExternalAccessConfiguration object in stateful set. + // For tests that don't set hostnames we preserve old behaviour. + hostnames := om.CurrMockedConnection.Hostnames + if hostnames == nil { + if val, ok := set.Annotations[handler.MongoDBMultiResourceAnnotation]; ok { + hostnames = dns.GetMultiClusterAgentHostnames(val, set.Namespace, multicluster.MustGetClusterNumFromMultiStsName(set.Name), int(*set.Spec.Replicas), nil) + } else { + // We also "register" automation agents. + // So far we don't support custom cluster name + hostnames, _ = dns.GetDnsForStatefulSet(*set, "", nil) + } + } + om.CurrMockedConnection.AddHosts(hostnames) + } +} + +func (oc *MockedClient) addToHistory(value reflect.Value, obj apiruntime.Object) { + oc.history = append(oc.history, HItem(value, obj)) +} + +func (m *MockedClient) GetMapForObject(obj apiruntime.Object) map[client.ObjectKey]apiruntime.Object { + t := reflect.TypeOf(obj) + if _, ok := m.backingMap[t]; !ok { + m.backingMap[t] = map[client.ObjectKey]apiruntime.Object{} + } + return m.backingMap[t] +} + +func (oc *MockedClient) CheckOrderOfOperations(t *testing.T, value ...*HistoryItem) { + j := 0 + matched := "" + for _, h := range oc.history { + if *h == *value[j] { + matched += fmt.Sprintf("%s ", h.String()) + j++ + } + if j == len(value) { + break + } + } + assert.Equal(t, len(value), j, "Only %d of %d expected operations happened in expected order (%s)", j, len(value), matched) +} + +func (oc *MockedClient) CheckNumberOfOperations(t *testing.T, value *HistoryItem, expected int) { + count := 0 + for _, h := range oc.history { + if *h == *value { + count++ + } + } + assert.Equal(t, expected, count, "Expected to have been %d %s operations but there were %d", expected, value.function.Name(), count) +} + +func (oc *MockedClient) CheckOperationsDidntHappen(t *testing.T, value ...*HistoryItem) { + for _, h := range oc.history { + for _, o := range value { + if *h == *o { + assert.Fail(t, "Operation is not expected to happen", "%v is not expected to happen", *h) + } + } + } +} + +func (oc *MockedClient) ClearHistory() { + oc.history = []*HistoryItem{} +} + +func (oc *MockedClient) GetSet(key client.ObjectKey) *appsv1.StatefulSet { + return oc.GetMapForObject(&appsv1.StatefulSet{})[key].(*appsv1.StatefulSet) +} + +// HistoryItem is an item that describe the invocation of 'client.client' method. +type HistoryItem struct { + function *runtime.Func + resourceType reflect.Type +} + +func HItem(value reflect.Value, obj apiruntime.Object) *HistoryItem { + historyItem := &HistoryItem{function: runtime.FuncForPC(value.Pointer())} + if obj != nil { + historyItem.resourceType = reflect.ValueOf(obj).Type() + } else { + historyItem.resourceType = nil + } + return historyItem +} + +func (h HistoryItem) String() string { + resourceTypeStr := "nil" + if h.resourceType != nil { + resourceTypeStr = h.resourceType.String() + } + return fmt.Sprintf("%s-%s", h.function.Name(), resourceTypeStr) +} + +// MockedManager is the mock implementation of `Manager` from controller-runtime library. The only interesting method though +// is `getClient` +type MockedManager struct { + Client *MockedClient +} + +func NewEmptyManager() *MockedManager { + return &MockedManager{Client: NewClient()} +} + +func NewManager(object client.Object) *MockedManager { + return &MockedManager{Client: NewClient().WithResource(object)} +} + +func NewManagerSpecificClient(c *MockedClient) *MockedManager { + return &MockedManager{Client: c} +} + +func (m *MockedManager) Add(runnable manager.Runnable) error { + return nil +} + +func (m *MockedManager) AddHealthzCheck(name string, check healthz.Checker) error { + return nil +} + +func (m *MockedManager) AddReadyzCheck(name string, check healthz.Checker) error { + return nil +} + +// SetFields will set any dependencies on an object for which the object has implemented the inject +// interface - e.g. inject.Client. +func (m *MockedManager) SetFields(interface{}) error { + return nil +} + +// Start starts all registered Controllers and blocks until the Stop channel is closed. +// Returns an error if there is an error starting any controller. +func (m *MockedManager) Start(_ context.Context) error { + return nil +} + +// GetConfig returns an initialized Config +func (m *MockedManager) GetConfig() *rest.Config { + return nil +} + +// GetScheme returns and initialized Scheme +func (m *MockedManager) GetScheme() *apiruntime.Scheme { + return nil +} + +// GetAdmissionDecoder returns the runtime.Decoder based on the scheme. +func (m *MockedManager) GetAdmissionDecoder() admission.Decoder { + // just returning nothing + d, _ := admission.NewDecoder(apiruntime.NewScheme()) + return *d +} + +// GetAPIReader returns the client reader +func (m *MockedManager) GetAPIReader() client.Reader { + return nil +} + +// GetClient returns a client configured with the Config +func (m *MockedManager) GetClient() client.Client { + return m.Client +} + +func (m *MockedManager) GetEventRecorderFor(name string) record.EventRecorder { + return nil +} + +// GetFieldIndexer returns a client.FieldIndexer configured with the client +func (m *MockedManager) GetFieldIndexer() client.FieldIndexer { + return nil +} + +// GetCache returns a cache.Cache +func (m *MockedManager) GetCache() cache.Cache { + return nil +} + +// GetRecorder returns a new EventRecorder for the provided name +func (m *MockedManager) GetRecorder(name string) record.EventRecorder { + return nil +} + +// GetRESTMapper returns a RESTMapper +func (m *MockedManager) GetRESTMapper() meta.RESTMapper { + return nil +} + +func (m *MockedManager) GetWebhookServer() *webhook.Server { + return nil +} + +func (m *MockedManager) AddMetricsExtraHandler(path string, handler http.Handler) error { + return nil +} + +func (m *MockedManager) Elected() <-chan struct{} { + return nil +} + +func (m *MockedManager) GetLogger() logr.Logger { + return logr.Logger{} +} + +func (m *MockedManager) GetControllerOptions() v1alpha1.ControllerConfigurationSpec { + var duration = time.Duration(0) + return v1alpha1.ControllerConfigurationSpec{ + CacheSyncTimeout: &duration, + } +} + +func ObjectKeyFromApiObject(obj interface{}) client.ObjectKey { + ns := reflect.ValueOf(obj).Elem().FieldByName("Namespace").String() + name := reflect.ValueOf(obj).Elem().FieldByName("Name").String() + + return types.NamespacedName{Name: name, Namespace: ns} +} + +// validateDNS1123Subdomain ensures that the given Kubernetes object has a name which adheres +// to DNS1123. +func validateDNS1123Subdomain(obj apiruntime.Object) error { + objName := reflect.ValueOf(obj).Elem().FieldByName("Name").String() + validationErrs := validation.IsDNS1123Subdomain(objName) + var errs error + if len(validationErrs) > 0 { + errs = multierror.Append(errs, xerrors.Errorf("resource name: [%s] failed validation of type %s", objName, reflect.TypeOf(obj))) + for _, err := range validationErrs { + errs = multierror.Append(errs, xerrors.Errorf(err)) + } + return errs + } + return nil +} diff --git a/controllers/operator/mock/test_fixtures.go b/controllers/operator/mock/test_fixtures.go new file mode 100644 index 000000000..cec38041e --- /dev/null +++ b/controllers/operator/mock/test_fixtures.go @@ -0,0 +1,26 @@ +package mock + +import ( + "os" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" +) + +func InitDefaultEnvVariables() { + os.Setenv(util.AutomationAgentImage, "mongodb-enterprise-database") + os.Setenv(util.AutomationAgentImagePullPolicy, "Never") + os.Setenv(util.OpsManagerImageUrl, "quay.io/mongodb/mongodb-enterprise-ops-manager") + os.Setenv(util.InitOpsManagerImageUrl, "quay.io/mongodb/mongodb-enterprise-init-ops-manager") + os.Setenv(util.InitAppdbImageUrlEnv, "quay.io/mongodb/mongodb-enterprise-init-appdb") + os.Setenv(util.InitDatabaseImageUrlEnv, "quay.io/mongodb/mongodb-enterprise-init-database") + os.Setenv(util.OpsManagerPullPolicy, "Never") + os.Setenv(util.OmOperatorEnv, "test") + os.Setenv(util.PodWaitSecondsEnv, "1") + os.Setenv(util.PodWaitRetriesEnv, "2") + os.Setenv(util.BackupDisableWaitSecondsEnv, "1") + os.Setenv(util.BackupDisableWaitRetriesEnv, "3") + os.Setenv(util.AppDBReadinessWaitEnv, "0") + os.Setenv(util.K8sCacheRefreshEnv, "0") + os.Unsetenv(util.ManagedSecurityContextEnv) + os.Unsetenv(util.ImagePullSecrets) +} diff --git a/controllers/operator/mongodbmultireplicaset_controller.go b/controllers/operator/mongodbmultireplicaset_controller.go new file mode 100644 index 000000000..4155825bf --- /dev/null +++ b/controllers/operator/mongodbmultireplicaset_controller.go @@ -0,0 +1,1122 @@ +package operator + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "sort" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/create" + + "golang.org/x/xerrors" + + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" + + "github.com/google/go-cmp/cmp" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/automationconfig" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/util/merge" + + "github.com/10gen/ops-manager-kubernetes/pkg/multicluster" + "github.com/10gen/ops-manager-kubernetes/pkg/multicluster/memberwatch" + "github.com/10gen/ops-manager-kubernetes/pkg/tls" + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/annotations" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/container" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/statefulset" + + enterprisests "github.com/10gen/ops-manager-kubernetes/pkg/statefulset" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/host" + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "github.com/hashicorp/go-multierror" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/intstr" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + mdbmultiv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdbmulti" + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/controllers/om/process" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/agents" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/authentication" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/certs" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/connection" + mconstruct "github.com/10gen/ops-manager-kubernetes/controllers/operator/construct/multicluster" + enterprisepem "github.com/10gen/ops-manager-kubernetes/controllers/operator/pem" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/project" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/secrets" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/watch" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/workflow" + "github.com/10gen/ops-manager-kubernetes/pkg/dns" + khandler "github.com/10gen/ops-manager-kubernetes/pkg/handler" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + kubernetesClient "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/client" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/configmap" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/secret" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/service" + "go.uber.org/zap" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/cluster" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" +) + +// ReconcileMongoDbMultiReplicaSet reconciles a MongoDB ReplicaSet across multiple Kubernetes clusters +type ReconcileMongoDbMultiReplicaSet struct { + *ReconcileCommonController + omConnectionFactory om.ConnectionFactory + memberClusterClientsMap map[string]kubernetesClient.Client // holds the client for each of the memberclusters(where the MongoDB ReplicaSet is deployed) + memberClusterSecretClientsMap map[string]secrets.SecretClient +} + +var _ reconcile.Reconciler = &ReconcileMongoDbMultiReplicaSet{} + +func newMultiClusterReplicaSetReconciler(mgr manager.Manager, omFunc om.ConnectionFactory, memberClustersMap map[string]cluster.Cluster) *ReconcileMongoDbMultiReplicaSet { + clientsMap := make(map[string]kubernetesClient.Client) + secretClientsMap := make(map[string]secrets.SecretClient) + + // extract client from each cluster object. + for k, v := range memberClustersMap { + clientsMap[k] = kubernetesClient.NewClient(v.GetClient()) + secretClientsMap[k] = secrets.SecretClient{ + VaultClient: nil, // Vault is not supported yet on multicluster + KubeClient: clientsMap[k], + } + } + + return &ReconcileMongoDbMultiReplicaSet{ + ReconcileCommonController: newReconcileCommonController(mgr), + omConnectionFactory: omFunc, + memberClusterClientsMap: clientsMap, + memberClusterSecretClientsMap: secretClientsMap, + } +} + +// MongoDBMultiCluster Resource +// +kubebuilder:rbac:groups=mongodb.com,resources={mongodbmulticluster,mongodbmulticluster/status,mongodbmulticluster/finalizers},verbs=*,namespace=placeholder + +// Reconcile reads that state of the cluster for a MongoDbMultiReplicaSet object and makes changes based on the state read +// and what is in the MongoDbMultiReplicaSet.Spec +func (r *ReconcileMongoDbMultiReplicaSet) Reconcile(_ context.Context, request reconcile.Request) (res reconcile.Result, e error) { + agents.UpgradeAllIfNeeded(r.client, r.SecretClient, r.omConnectionFactory, GetWatchedNamespace()) + + log := zap.S().With("MultiReplicaSet", request.NamespacedName) + log.Info("-> MultiReplicaSet.Reconcile") + + // Fetch the MongoDBMultiCluster instance + mrs := mdbmultiv1.MongoDBMultiCluster{} + if reconcileResult, err := r.prepareResourceForReconciliation(request, &mrs, log); err != nil { + if apiErrors.IsNotFound(err) { + return reconcile.Result{}, nil + } + log.Errorf("error preparing resource for reconciliation: %s", err) + return reconcileResult, err + } + + projectConfig, credsConfig, err := project.ReadConfigAndCredentials(r.client, r.SecretClient, &mrs, log) + if err != nil { + return r.updateStatus(&mrs, workflow.Failed(xerrors.Errorf("Error reading project config and credentials: %w", err)), log) + } + + conn, err := connection.PrepareOpsManagerConnection(r.SecretClient, projectConfig, credsConfig, r.omConnectionFactory, mrs.Namespace, log) + if err != nil { + return r.updateStatus(&mrs, workflow.Failed(xerrors.Errorf("error establishing connection to Ops Manager: %w", err)), log) + } + + log = log.With("MemberCluster Namespace", mrs.Namespace) + + // check if resource has failedCluster annotation and mark it as failed if automated failover is not enabled + failedClusterNames, err := mrs.GetFailedClusterNames() + if err != nil { + return r.updateStatus(&mrs, workflow.Failed(err), log) + } + if len(failedClusterNames) > 0 && !multicluster.ShouldPerformFailover() { + return r.updateStatus(&mrs, workflow.Failed(xerrors.Errorf("resource has failed clusters in the annotation: %+v", failedClusterNames)), log) + } + + r.SetupCommonWatchers(&mrs, nil, nil, mrs.Name) + + needToPublishStateFirst, err := r.needToPublishStateFirstMultiCluster(&mrs, log) + if err != nil { + return r.updateStatus(&mrs, workflow.Failed(err), log) + } + + status := workflow.RunInGivenOrder(needToPublishStateFirst, + func() workflow.Status { + if err := r.updateOmDeploymentRs(conn, mrs, log); err != nil { + return workflow.Failed(err) + } + return workflow.OK() + }, + func() workflow.Status { + return r.reconcileMemberResources(mrs, log, conn, projectConfig) + }) + + if !status.IsOK() { + return r.updateStatus(&mrs, status, log) + } + + if err := r.saveLastAchievedSpec(mrs); err != nil { + return r.updateStatus(&mrs, workflow.Failed(xerrors.Errorf("Failed to set annotation: %w", err)), log) + } + + // for purposes of comparison, we don't want to compare entries with 0 members since they will not be present + // as a desired entry. + desiredSpecList := mrs.GetDesiredSpecList() + actualSpecList, err := mrs.GetClusterSpecItems() + if err != nil { + return r.updateStatus(&mrs, workflow.Failed(err), log) + } + + effectiveSpecList := filterClusterSpecItem(actualSpecList, func(item mdbmultiv1.ClusterSpecItem) bool { + return item.Members > 0 + }) + + // sort both actual and desired to match the effective and desired list version before comparing + sortClusterSpecList(desiredSpecList) + sortClusterSpecList(effectiveSpecList) + + needToRequeue := !clusterSpecListsEqual(effectiveSpecList, desiredSpecList) + if needToRequeue { + return r.updateStatus(&mrs, workflow.Pending("MongoDBMultiCluster deployment is not yet ready, requeuing reconciliation."), log) + } + + log.Infow("Finished reconciliation for MultiReplicaSet", "Spec", mrs.Spec, "Status", mrs.Status) + return r.updateStatus(&mrs, workflow.OK(), log) +} + +// needToPublishStateFirstMultiCluster returns a boolean indicating whether or not Ops Manager +// needs to be updated before the StatefulSets are created for this resource. +func (r *ReconcileMongoDbMultiReplicaSet) needToPublishStateFirstMultiCluster(mrs *mdbmultiv1.MongoDBMultiCluster, log *zap.SugaredLogger) (bool, error) { + scalingDown, err := isScalingDown(mrs) + if err != nil { + return false, xerrors.Errorf("failed determining if the resource is scaling down: %w", err) + } + + if scalingDown { + log.Infof("Scaling down in progress, updating Ops Manager state first.") + return true, nil + } + + firstStatefulSet, err := r.firstStatefulSet(mrs) + if err != nil { + if apiErrors.IsNotFound(err) { + // No need to publish state as this is a new StatefulSet + log.Debugf("New StatefulSet %s", firstStatefulSet.GetName()) + return false, nil + } + return false, err + } + + databaseContainer := container.GetByName(util.DatabaseContainerName, firstStatefulSet.Spec.Template.Spec.Containers) + volumeMounts := databaseContainer.VolumeMounts + if mrs.Spec.Security != nil { + if !mrs.Spec.Security.IsTLSEnabled() && statefulset.VolumeMountWithNameExists(volumeMounts, util.SecretVolumeName) { + log.Debug("About to set `security.tls.enabled` to false. automationConfig needs to be updated first") + return true, nil + } + + if mrs.Spec.Security.TLSConfig.CA == "" && statefulset.VolumeMountWithNameExists(volumeMounts, tls.ConfigMapVolumeCAName) { + log.Debug("About to set `security.tls.CA` to empty. automationConfig needs to be updated first") + return true, nil + } + } + + return false, nil +} + +// isScalingDown returns true if the MongoDBMultiCluster is attempting to scale down. +func isScalingDown(mrs *mdbmultiv1.MongoDBMultiCluster) (bool, error) { + desiredSpec := mrs.Spec.GetClusterSpecList() + + specThisReconciliation, err := mrs.GetClusterSpecItems() + if err != nil { + return false, err + } + + if len(desiredSpec) < len(specThisReconciliation) { + return true, nil + } + + for i := 0; i < len(specThisReconciliation); i++ { + specItem := desiredSpec[i] + reconciliationItem := specThisReconciliation[i] + + if specItem.Members < reconciliationItem.Members { + // when failover is happening, the clusterspec list will alaways have fewer members + // than the specs for the reoconcile. + if _, ok := mdbmultiv1.HasClustersToFailOver(mrs.Annotations); ok { + return false, nil + } + return true, nil + } + + } + + return false, nil +} + +func (r *ReconcileMongoDbMultiReplicaSet) firstStatefulSet(mrs *mdbmultiv1.MongoDBMultiCluster) (appsv1.StatefulSet, error) { + // We want to get an existing statefulset, so we should fetch the client from "mrs.Spec.ClusterSpecList.ClusterSpecs" + // instead of mrs.GetClusterSpecItems(), since the later returns the effective clusterspecs, which might return + // clusters which have been removed and do not have a running statefulset. + items := mrs.Spec.ClusterSpecList + var firstMemberClient kubernetesClient.Client + var firstMemberIdx int + foundOne := false + for idx, item := range items { + client, ok := r.memberClusterClientsMap[item.ClusterName] + if ok { + firstMemberClient = client + firstMemberIdx = idx + foundOne = true + break + } + } + if !foundOne { + return appsv1.StatefulSet{}, xerrors.Errorf("was not able to find given member clusters in client map") + } + stsName := kube.ObjectKey(mrs.Namespace, mrs.MultiStatefulsetName(mrs.ClusterNum(items[firstMemberIdx].ClusterName))) + + firstStatefulSet, err := firstMemberClient.GetStatefulSet(stsName) + if err != nil { + if apiErrors.IsNotFound(err) { + return firstStatefulSet, err + } + return firstStatefulSet, xerrors.Errorf("error getting StatefulSet %s: %w", stsName, err) + } + return firstStatefulSet, err +} + +// reconcileMemberResources handles the synchronization of kubernetes resources, which can be statefulsets, services etc. +// All the resources required in the k8s cluster (as opposed to the automation config) for creating the replicaset +// should be reconciled in this method. +func (r *ReconcileMongoDbMultiReplicaSet) reconcileMemberResources(mrs mdbmultiv1.MongoDBMultiCluster, log *zap.SugaredLogger, conn om.Connection, projectConfig mdbv1.ProjectConfig) workflow.Status { + err := r.reconcileServices(log, &mrs) + if err != nil { + return workflow.Failed(err) + } + + // create configmap with the hostname-override + err = r.reconcileHostnameOverrideConfigMap(log, mrs) + if err != nil { + return workflow.Failed(err) + } + + // Copy over OM CustomCA if specified in project config + if projectConfig.SSLMMSCAConfigMap != "" { + err = r.reconcileOMCAConfigMap(log, mrs, projectConfig.SSLMMSCAConfigMap) + if err != nil { + return workflow.Failed(err) + } + } + // Ensure custom roles are created in OM + if status := ensureRoles(mrs.GetSecurity().Roles, conn, log); !status.IsOK() { + return status + } + + return r.reconcileStatefulSets(mrs, log, conn, projectConfig) +} + +func (r *ReconcileMongoDbMultiReplicaSet) reconcileStatefulSets(mrs mdbmultiv1.MongoDBMultiCluster, log *zap.SugaredLogger, conn om.Connection, projectConfig mdbv1.ProjectConfig) workflow.Status { + clusterSpecList, err := mrs.GetClusterSpecItems() + if err != nil { + return workflow.Failed(xerrors.Errorf("failed to read cluster spec list: %w", err)) + } + failedClusterNames, err := mrs.GetFailedClusterNames() + if err != nil { + log.Errorf("failed retrieving list of failed clusters: %s", err.Error()) + } + + for _, item := range clusterSpecList { + if stringutil.Contains(failedClusterNames, item.ClusterName) { + log.Warnf(fmt.Sprintf("failed to reconcile statefulset: cluster %s is marked as failed", item.ClusterName)) + continue + } + + memberClient, ok := r.memberClusterClientsMap[item.ClusterName] + if !ok { + log.Warnf(fmt.Sprintf("failed to reconcile statefulset: cluster %s missing from client map", item.ClusterName)) + continue + } + secretMemberClient := r.memberClusterSecretClientsMap[item.ClusterName] + replicasThisReconciliation, err := getMembersForClusterSpecItemThisReconciliation(&mrs, item) + clusterNum := mrs.ClusterNum(item.ClusterName) + if err != nil { + return workflow.Failed(err) + } + + // Copy over the CA config map if it exists on the central cluster + caConfigMapName := mrs.Spec.Security.TLSConfig.CA + if caConfigMapName != "" { + cm, err := r.client.GetConfigMap(kube.ObjectKey(mrs.Namespace, caConfigMapName)) + if err != nil { + return workflow.Failed(xerrors.Errorf("Expected CA ConfigMap not found on central cluster: %s", caConfigMapName)) + } + + memberCm := configmap.Builder().SetName(caConfigMapName).SetNamespace(mrs.Namespace).SetData(cm.Data).Build() + err = configmap.CreateOrUpdate(memberClient, memberCm) + + if err != nil && !apiErrors.IsAlreadyExists(err) { + return workflow.Failed(xerrors.Errorf("Failed to sync CA ConfigMap in cluster: %s, err: %w", item.ClusterName, err)) + } + } + + // Ensure TLS for multi-cluster statefulset in each cluster + mrsConfig := certs.MultiReplicaSetConfig(mrs, clusterNum, item.ClusterName, replicasThisReconciliation) + if status := certs.EnsureSSLCertsForStatefulSet(r.SecretClient, secretMemberClient, *mrs.Spec.Security, mrsConfig, log); !status.IsOK() { + return status + } + + currentAgentAuthMode, err := conn.GetAgentAuthMode() + if err != nil { + return workflow.Failed(xerrors.Errorf("Failed to retrieve current agent auth mode in cluster: %s, err: %w", item.ClusterName, err)) + } + certConfigurator := certs.MongoDBMultiX509CertConfigurator{ + MongoDBMultiCluster: &mrs, + ClusterNum: clusterNum, + Replicas: replicasThisReconciliation, + SecretReadClient: r.SecretClient, + SecretWriteClient: secretMemberClient, + } + if status := r.ensureX509SecretAndCheckTLSType(certConfigurator, currentAgentAuthMode, log); !status.IsOK() { + return status + } + + // copy the agent api key to the member cluster. + apiKeySecretName := fmt.Sprintf("%s-group-secret", conn.GroupID()) + secretByte, err := secret.ReadByteData(r.client, types.NamespacedName{Name: apiKeySecretName, Namespace: mrs.Namespace}) + if err != nil { + return workflow.Failed(err) + } + + secretObject := corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: apiKeySecretName, + Namespace: mrs.Namespace, + Labels: mongoDBMultiLabels(mrs.Name, mrs.Namespace), + }, + Data: secretByte, + } + + err = secret.CreateOrUpdate(memberClient, secretObject) + if err != nil { + return workflow.Failed(err) + } + + // get cert hash of tls secret if it exists + certHash := enterprisepem.ReadHashFromSecret(r.SecretClient, mrs.Namespace, mrsConfig.CertSecretName, "", log) + internalCertHash := enterprisepem.ReadHashFromSecret(r.SecretClient, mrs.Namespace, mrsConfig.InternalClusterSecretName, "", log) + log.Debugf("Creating StatefulSet %s with %d replicas in cluster: %s", mrs.MultiStatefulsetName(clusterNum), replicasThisReconciliation, item.ClusterName) + + stsOverride := appsv1.StatefulSetSpec{} + if item.StatefulSetConfiguration != nil { + stsOverride = item.StatefulSetConfiguration.SpecWrapper.Spec + } + + opts := mconstruct.MultiClusterReplicaSetOptions( + mconstruct.WithClusterNum(clusterNum), + mconstruct.WithMemberCount(replicasThisReconciliation), + mconstruct.WithStsOverride(&stsOverride), + mconstruct.WithAnnotations(mrs.Name, certHash), + PodEnvVars(newPodVars(conn, projectConfig, mrs.Spec.ConnectionSpec)), + CurrentAgentAuthMechanism(currentAgentAuthMode), + CertificateHash(certHash), + InternalClusterHash(internalCertHash), + WithLabels(mongoDBMultiLabels(mrs.Name, mrs.Namespace)), + ) + + sts := mconstruct.MultiClusterStatefulSet(mrs, opts) + deleteSts, err := shouldDeleteStatefulSet(mrs, item) + if err != nil { + return workflow.Failed(xerrors.Errorf("failed to create StatefulSet in cluster: %s, err: %w", item.ClusterName, err)) + } + + if deleteSts { + if err := memberClient.Delete(context.TODO(), &sts); err != nil && !apiErrors.IsNotFound(err) { + return workflow.Failed(xerrors.Errorf("failed to delete StatefulSet in cluster: %s, err: %w", item.ClusterName, err)) + } + continue + } + + _, err = enterprisests.CreateOrUpdateStatefulset(memberClient, mrs.Namespace, log, &sts) + if err != nil { + return workflow.Failed(xerrors.Errorf("failed to create/update StatefulSet in cluster: %s, err: %w", item.ClusterName, err)) + } + + if status := getStatefulSetStatus(sts.Namespace, sts.Name, memberClient); !status.IsOK() { + return status + } + + log.Infof("Successfully ensured StatefulSet in cluster: %s", item.ClusterName) + } + + return workflow.OK() +} + +// shouldDeleteStatefulSet returns a boolean value indicating whether or not the StatefulSet associated with +// the given cluster spec item should be deleted or not. +func shouldDeleteStatefulSet(mrs mdbmultiv1.MongoDBMultiCluster, item mdbmultiv1.ClusterSpecItem) (bool, error) { + for _, specItem := range mrs.Spec.ClusterSpecList { + if item.ClusterName == specItem.ClusterName { + // this spec value has been explicitly defined, don't delete it. + return false, nil + } + } + + items, err := mrs.GetClusterSpecItems() + if err != nil { + return false, err + } + + for _, specItem := range items { + if item.ClusterName == specItem.ClusterName { + // we delete only if we have fully scaled down and are at 0 members + return specItem.Members == 0, nil + } + } + + // we are in the process of scaling down to 0, and should not yet delete the statefulset + return false, nil +} + +// getMembersForClusterSpecItemThisReconciliation returns the value members should have for a given cluster spec item +// for a given reconciliation. This value should increment or decrement in one cluster by one member each reconciliation +// when a scaling operation is taking place. +func getMembersForClusterSpecItemThisReconciliation(mrs *mdbmultiv1.MongoDBMultiCluster, item mdbmultiv1.ClusterSpecItem) (int, error) { + clusterSpecList, err := mrs.GetClusterSpecItems() + if err != nil { + return -1, err + } + for _, clusterItem := range clusterSpecList { + if clusterItem.ClusterName == item.ClusterName { + return clusterItem.Members, nil + } + } + return -1, xerrors.Errorf("did not find %s in cluster spec list", item.ClusterName) +} + +// saveLastAchievedSpec updates the MongoDBMultiCluster resource with the spec that was just achieved. +func (r *ReconcileMongoDbMultiReplicaSet) saveLastAchievedSpec(mrs mdbmultiv1.MongoDBMultiCluster) error { + clusterSpecs, err := mrs.GetClusterSpecItems() + if err != nil { + return err + } + + lastAchievedSpec := mrs.Spec + lastAchievedSpec.ClusterSpecList = clusterSpecs + achievedSpecBytes, err := json.Marshal(lastAchievedSpec) + if err != nil { + return err + } + + if mrs.Annotations == nil { + mrs.Annotations = map[string]string{} + } + + // TODO Find a way to avoid using the spec for this field as we're not writing the information + // back in the resource and the user does not set it. + clusterNumBytes, err := json.Marshal(mrs.Spec.Mapping) + if err != nil { + return err + } + annotationsToAdd := make(map[string]string) + + annotationsToAdd[util.LastAchievedSpec] = string(achievedSpecBytes) + if string(clusterNumBytes) != "null" { + annotationsToAdd[mdbmultiv1.LastClusterNumMapping] = string(clusterNumBytes) + } + + return annotations.SetAnnotations(&mrs, annotationsToAdd, r.client) +} + +// updateOmDeploymentRs performs OM registration operation for the replicaset. So the changes will be finally propagated +// to automation agents in containers +func (r *ReconcileMongoDbMultiReplicaSet) updateOmDeploymentRs(conn om.Connection, mrs mdbmultiv1.MongoDBMultiCluster, log *zap.SugaredLogger) error { + hostnames := make([]string, 0) + + clusterSpecList, err := mrs.GetClusterSpecItems() + if err != nil { + return err + } + + for _, spec := range clusterSpecList { + hostnamesToAdd := dns.GetMultiClusterAgentHostnames(mrs.Name, mrs.Namespace, mrs.ClusterNum(spec.ClusterName), spec.Members, spec.ExternalAccessConfiguration.ExternalDomain) + hostnames = append(hostnames, hostnamesToAdd...) + } + + err = agents.WaitForRsAgentsToRegisterReplicasSpecifiedMultiCluster(conn, hostnames, log) + if err != nil { + return err + } + + processIds, err := getExistingProcessIds(conn, mrs) + if err != nil { + return err + } + log.Debugf("Existing process Ids: %+v", processIds) + + certificateFileName := "" + internalClusterPath := "" + + // If tls is enabled we need to configure the "processes" array in opsManager/Cloud Manager with the + // correct certFilePath, with the new tls design, this path has the certHash in it(so that cert can be rotated + // without pod restart), we can get the cert hash from any of the statefulset, here we pick the statefulset in the first cluster. + if mrs.Spec.Security.IsTLSEnabled() { + firstStatefulSet, err := r.firstStatefulSet(&mrs) + if err != nil { + return err + } + + if hash := firstStatefulSet.Annotations[util.InternalCertAnnotationKey]; hash != "" { + internalClusterPath = fmt.Sprintf("%s%s", util.InternalClusterAuthMountPath, hash) + } + + if certificateHash := firstStatefulSet.Annotations[certs.CertHashAnnotationKey]; certificateHash != "" { + certificateFileName = fmt.Sprintf("%s/%s", util.TLSCertMountPath, certificateHash) + } + } + + processes, err := process.CreateMongodProcessesWithLimitMulti(mrs, certificateFileName) + if err != nil { + return err + } + + if len(processes) != len(mrs.Spec.GetMemberOptions()) { + log.Warnf("the number of member options is different than the number of mongod processes to be created: %d processes - %d replica set member options", len(processes), len(mrs.Spec.GetMemberOptions())) + } + rs := om.NewMultiClusterReplicaSetWithProcesses(om.NewReplicaSet(mrs.Name, mrs.Spec.Version), processes, mrs.Spec.GetMemberOptions(), processIds, mrs.Spec.Connectivity) + + caFilePath := fmt.Sprintf("%s/ca-pem", util.TLSCaMountPath) + + // We do not provide an agentCertSecretName on purpose because then we will default to the non pem secret on the central cluster. + // Below method has special code handling reading certificates from the central cluster in that case. + status, additionalReconciliationRequired := r.updateOmAuthentication(conn, rs.GetProcessNames(), &mrs, "", caFilePath, internalClusterPath, log) + if !status.IsOK() { + return xerrors.Errorf("failed to enable Authentication for MongoDB Multi Replicaset") + } + + lastMongodbConfig := mrs.GetLastAdditionalMongodConfig() + + err = conn.ReadUpdateDeployment( + func(d om.Deployment) error { + return ReconcileReplicaSetAC(d, processes, mrs.Spec.DbCommonSpec, lastMongodbConfig, mrs.Name, rs, caFilePath, internalClusterPath, nil, log) + }, + log, + ) + if err != nil { + return err + } + + if additionalReconciliationRequired { + // TODO: fix this decide when to use Pending vs Reconciling + return xerrors.Errorf("failed to complete reconciliation") + } + + status = r.ensureBackupConfigurationAndUpdateStatus(conn, &mrs, r.SecretClient, log) + if !status.IsOK() { + return xerrors.Errorf("failed to configure backup for MongoDBMultiCluster RS") + } + + if err := om.WaitForReadyState(conn, rs.GetProcessNames(), log); err != nil { + return err + } + return nil +} + +func getExistingProcessIds(conn om.Connection, mrs mdbmultiv1.MongoDBMultiCluster) (map[string]int, error) { + existingDeployment, err := conn.ReadDeployment() + if err != nil { + return nil, err + } + + processIds := map[string]int{} + for _, rs := range existingDeployment.ReplicaSetsCopy() { + if rs.Name() != mrs.Name { + continue + } + for _, m := range rs.Members() { + processIds[m.Name()] = m.Id() + } + } + return processIds, nil +} + +func mongoDBMultiLabels(name, namespace string) map[string]string { + return map[string]string{ + "controller": "mongodb-enterprise-operator", + "mongodbmulticluster": fmt.Sprintf("%s-%s", namespace, name), + } +} + +func getSRVService(mrs *mdbmultiv1.MongoDBMultiCluster) corev1.Service { + svcLabels := mongoDBMultiLabels(mrs.Name, mrs.Namespace) + + additionalConfig := mrs.Spec.GetAdditionalMongodConfig() + port := additionalConfig.GetPortOrDefault() + + svc := service.Builder(). + SetName(fmt.Sprintf("%s-svc", mrs.Name)). + SetNamespace(mrs.Namespace). + SetSelector(mconstruct.PodLabel(mrs.Name)). + SetLabels(svcLabels). + SetPublishNotReadyAddresses(true). + AddPort(&corev1.ServicePort{Port: port, Name: "mongodb"}). + AddPort(&corev1.ServicePort{Port: create.GetNonEphemeralBackupPort(port), Name: "backup", TargetPort: intstr.IntOrString{IntVal: create.GetNonEphemeralBackupPort(port)}}). + Build() + + return svc +} + +func getExternalService(mrs *mdbmultiv1.MongoDBMultiCluster, clusterName string, podNum int) corev1.Service { + clusterNum := mrs.ClusterNum(clusterName) + + svc := getService(mrs, clusterName, podNum) + svc.Name = dns.GetMultiExternalServiceName(mrs.GetName(), clusterNum, podNum) + svc.Spec.Type = corev1.ServiceTypeLoadBalancer + + externalDomain := mrs.ExternalMemberClusterDomain(clusterName) + if externalDomain != nil { + // first we override with the Service spec from the root and then from a specific cluster. + if mrs.Spec.ExternalAccessConfiguration != nil { + globalOverrideSpecWrapper := mrs.Spec.ExternalAccessConfiguration.ExternalService.SpecWrapper + if globalOverrideSpecWrapper != nil { + svc.Spec = merge.ServiceSpec(svc.Spec, globalOverrideSpecWrapper.Spec) + } + } + + clusterLevelOverrideSpec := mrs.Spec.ClusterSpecList[clusterNum].ExternalAccessConfiguration.ExternalService.SpecWrapper + additionalAnnotations := mrs.Spec.ClusterSpecList[clusterNum].ExternalAccessConfiguration.ExternalService.Annotations + if clusterLevelOverrideSpec != nil { + svc.Spec = merge.ServiceSpec(svc.Spec, clusterLevelOverrideSpec.Spec) + } + svc.Annotations = merge.StringToStringMap(svc.Annotations, additionalAnnotations) + } + + return svc +} + +func getService(mrs *mdbmultiv1.MongoDBMultiCluster, clusterName string, podNum int) corev1.Service { + svcLabels := map[string]string{ + "statefulset.kubernetes.io/pod-name": dns.GetMultiPodName(mrs.Name, mrs.ClusterNum(clusterName), podNum), + "controller": "mongodb-enterprise-operator", + "mongodbmulticluster": fmt.Sprintf("%s-%s", mrs.Namespace, mrs.Name), + } + + labelSelectors := map[string]string{ + "statefulset.kubernetes.io/pod-name": dns.GetMultiPodName(mrs.Name, mrs.ClusterNum(clusterName), podNum), + "controller": "mongodb-enterprise-operator", + } + + additionalConfig := mrs.Spec.GetAdditionalMongodConfig() + port := additionalConfig.GetPortOrDefault() + + svc := service.Builder(). + SetName(dns.GetMultiServiceName(mrs.Name, mrs.ClusterNum(clusterName), podNum)). + SetNamespace(mrs.Namespace). + SetSelector(labelSelectors). + SetLabels(svcLabels). + SetPublishNotReadyAddresses(true). + AddPort(&corev1.ServicePort{Port: port, Name: "mongodb"}). + // Note: in the agent-launcher.sh We explicitly pass an offset of 1. When port N is exposed + // the agent would use port N+1 for the spinning up of the ephemeral mongod process, which is used for backup + AddPort(&corev1.ServicePort{Port: create.GetNonEphemeralBackupPort(port), Name: "backup", TargetPort: intstr.IntOrString{IntVal: create.GetNonEphemeralBackupPort(port)}}). + Build() + + return svc +} + +// reconcileServices makes sure that we have a service object corresponding to each statefulset pod +// in the member clusters +func (r *ReconcileMongoDbMultiReplicaSet) reconcileServices(log *zap.SugaredLogger, mrs *mdbmultiv1.MongoDBMultiCluster) error { + clusterSpecList, err := mrs.GetClusterSpecItems() + if err != nil { + return err + } + failedClusterNames, err := mrs.GetFailedClusterNames() + if err != nil { + log.Errorf("failed retrieving list of failed clusters: %s", err.Error()) + } + + // by default, we would create the duplicate services + shouldCreateDuplicates := mrs.Spec.DuplicateServiceObjects == nil || *mrs.Spec.DuplicateServiceObjects + if shouldCreateDuplicates { + // iterate over each cluster and create service object corresponding to each of the pods in the multi-cluster RS. + for k, v := range r.memberClusterClientsMap { + for _, e := range clusterSpecList { + if stringutil.Contains(failedClusterNames, e.ClusterName) { + log.Warnf("failed to create duplicate services: cluster %s is marked as failed", e.ClusterName) + continue + } + for podNum := 0; podNum < e.Members; podNum++ { + var svc corev1.Service + if mrs.ExternalMemberClusterDomain(e.ClusterName) != nil { + svc = getExternalService(mrs, e.ClusterName, podNum) + } else { + svc = getService(mrs, e.ClusterName, podNum) + } + err := service.CreateOrUpdateService(v, svc) + if err != nil && !apiErrors.IsAlreadyExists(err) { + return xerrors.Errorf("failed to created service: %s in cluster: %s, err: %w", svc.Name, k, err) + } + log.Infof("Successfully created services in cluster: %s", k) + } + } + } + return nil + } + + for _, e := range clusterSpecList { + if stringutil.Contains(failedClusterNames, e.ClusterName) { + log.Warnf(fmt.Sprintf("failed to create services: cluster %s is marked as failed", e.ClusterName)) + continue + } + + client, ok := r.memberClusterClientsMap[e.ClusterName] + if !ok { + log.Warnf(fmt.Sprintf("failed to create services: cluster %s missing from client map", e.ClusterName)) + continue + } + if e.Members == 0 { + log.Warnf("skipping services creation: no members assigned to cluster %s", e.ClusterName) + continue + } + + srvService := getSRVService(mrs) + err = service.CreateOrUpdateService(client, srvService) + if err != nil && !apiErrors.IsAlreadyExists(err) { + return xerrors.Errorf("failed to create service: % in cluster: %s, err: %w", srvService.Name, e.ClusterName, err) + } + log.Infof("Successfully created srv service: %s in cluster: %s", srvService.Name, e.ClusterName) + + for podNum := 0; podNum < e.Members; podNum++ { + var svc corev1.Service + if mrs.ExternalMemberClusterDomain(e.ClusterName) != nil { + svc = getExternalService(mrs, e.ClusterName, podNum) + } else { + svc = getService(mrs, e.ClusterName, podNum) + + } + err := service.CreateOrUpdateService(client, svc) + if err != nil && !apiErrors.IsAlreadyExists(err) { + return xerrors.Errorf("failed to created service: %s in cluster: %s, err: %w", svc.Name, e.ClusterName, err) + } + log.Infof("Successfully created services in cluster: %s", e.ClusterName) + } + } + return nil +} + +func getHostnameOverrideConfigMap(mrs mdbmultiv1.MongoDBMultiCluster, clusterNum int, clusterName string, members int) corev1.ConfigMap { + data := make(map[string]string) + + externalDomain := mrs.ExternalMemberClusterDomain(clusterName) + for podNum := 0; podNum < members; podNum++ { + key := dns.GetMultiPodName(mrs.Name, clusterNum, podNum) + var value string + if externalDomain != nil { + value = dns.GetMultiServiceExternalDomain(mrs.Name, *externalDomain, clusterNum, podNum) + } else { + value = dns.GetMultiServiceFQDN(mrs.Name, mrs.Namespace, clusterNum, podNum) + } + data[key] = value + } + + cm := corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-hostname-override", mrs.Name), + Namespace: mrs.Namespace, + Labels: mongoDBMultiLabels(mrs.Name, mrs.Namespace), + }, + Data: data, + } + return cm +} + +func (r *ReconcileMongoDbMultiReplicaSet) reconcileHostnameOverrideConfigMap(log *zap.SugaredLogger, mrs mdbmultiv1.MongoDBMultiCluster) error { + clusterSpecList, err := mrs.GetClusterSpecItems() + if err != nil { + return err + } + failedClusterNames, err := mrs.GetFailedClusterNames() + if err != nil { + log.Warnf("failed retrieving list of failed clusters: %s", err.Error()) + } + + for i, e := range clusterSpecList { + if stringutil.Contains(failedClusterNames, e.ClusterName) { + log.Warnf(fmt.Sprintf("failed to create configmap: cluster %s is marked as failed", e.ClusterName)) + continue + } + + client, ok := r.memberClusterClientsMap[e.ClusterName] + if !ok { + log.Warnf(fmt.Sprintf("failed to create configmap: cluster %s is missing from client map", e.ClusterName)) + continue + } + cm := getHostnameOverrideConfigMap(mrs, i, e.ClusterName, e.Members) + + err = configmap.CreateOrUpdate(client, cm) + if err != nil && !apiErrors.IsAlreadyExists(err) { + return xerrors.Errorf("failed to create configmap: %s in cluster: %s, err: %w", cm.Name, e.ClusterName, err) + } + log.Infof("Successfully ensured configmap: %s in cluster: %s", cm.Name, e.ClusterName) + + } + return nil +} + +func (r *ReconcileMongoDbMultiReplicaSet) reconcileOMCAConfigMap(log *zap.SugaredLogger, mrs mdbmultiv1.MongoDBMultiCluster, configMapName string) error { + clusterSpecList, err := mrs.GetClusterSpecItems() + if err != nil { + return err + } + failedClusterNames, err := mrs.GetFailedClusterNames() + if err != nil { + log.Warnf("failed retrieving list of failed clusters: %s", err.Error()) + } + + cm, err := r.client.GetConfigMap(kube.ObjectKey(mrs.Namespace, configMapName)) + if err != nil { + return err + } + for _, cluster := range clusterSpecList { + if stringutil.Contains(failedClusterNames, cluster.ClusterName) { + log.Warnf("failed to create configmap %s: cluster %s is marked as failed", configMapName, cluster.ClusterName) + continue + } + client := r.memberClusterClientsMap[cluster.ClusterName] + memberCm := configmap.Builder().SetName(configMapName).SetNamespace(mrs.Namespace).SetData(cm.Data).Build() + err := configmap.CreateOrUpdate(client, memberCm) + if err != nil && !apiErrors.IsAlreadyExists(err) { + return xerrors.Errorf("failed to create configmap: %s in cluster %s, err: %w", cm.Name, cluster.ClusterName, err) + } + log.Infof("Sucessfully ensured configmap: %s in cluster: %s", cm.Name, cluster.ClusterName) + } + return nil +} + +// AddMultiReplicaSetController creates a new MongoDbMultiReplicaset Controller and adds it to the Manager. The Manager will set fields on the Controller +// and Start it when the Manager is Started. +func AddMultiReplicaSetController(mgr manager.Manager, memberClustersMap map[string]cluster.Cluster) error { + reconciler := newMultiClusterReplicaSetReconciler(mgr, om.NewOpsManagerConnection, memberClustersMap) + c, err := controller.New(util.MongoDbMultiClusterController, mgr, controller.Options{Reconciler: reconciler}) + if err != nil { + return err + } + + eventHandler := ResourceEventHandler{deleter: reconciler} + err = c.Watch(&source.Kind{Type: &mdbmultiv1.MongoDBMultiCluster{}}, &eventHandler, predicate.Funcs{ + UpdateFunc: func(e event.UpdateEvent) bool { + oldResource := e.ObjectOld.(*mdbmultiv1.MongoDBMultiCluster) + newResource := e.ObjectNew.(*mdbmultiv1.MongoDBMultiCluster) + + oldSpecAnnotation := oldResource.GetAnnotations()[util.LastAchievedSpec] + newSpecAnnotation := newResource.GetAnnotations()[util.LastAchievedSpec] + + // don't handle an update to just the previous spec annotation if they are not the same. + // this prevents the operator triggering reconciliations on resource that it is updating itself. + if !reflect.DeepEqual(oldSpecAnnotation, newSpecAnnotation) { + return false + } + + return reflect.DeepEqual(oldResource.GetStatus(), newResource.GetStatus()) + }, + }) + if err != nil { + return err + } + + err = c.Watch(&source.Kind{Type: &corev1.Secret{}}, + &watch.ResourcesHandler{ResourceType: watch.Secret, TrackedResources: reconciler.WatchedResources}) + if err != nil { + return err + } + + // register watcher across member clusters + for k, v := range memberClustersMap { + err := c.Watch(source.NewKindWithCache(&appsv1.StatefulSet{}, v.GetCache()), &khandler.EnqueueRequestForOwnerMultiCluster{}, watch.PredicatesForMultiStatefulSet()) + if err != nil { + return xerrors.Errorf("failed to set Watch on member cluster: %s, err: %w", k, err) + } + } + + // the operator watches the member clusters' API servers to determine whether the clusters are healthy or not + eventChannel := make(chan event.GenericEvent) + memberClusterHealthChecker := memberwatch.MemberClusterHealthChecker{Cache: make(map[string]*memberwatch.MemberHeathCheck)} + go memberClusterHealthChecker.WatchMemberClusterHealth(zap.S(), eventChannel, reconciler.client, memberClustersMap) + + err = c.Watch( + &source.Channel{Source: eventChannel}, + &handler.EnqueueRequestForObject{}, + ) + if err != nil { + zap.S().Errorf("failed to watch for member cluster healthcheck: %w", err) + } + + err = c.Watch(&source.Kind{Type: &corev1.ConfigMap{}}, + watch.ConfigMapEventHandler{ + ConfigMapName: util.MemberListConfigMapName, + ConfigMapNamespace: env.ReadOrPanic(util.CurrentNamespace), + }, + predicate.ResourceVersionChangedPredicate{}, + ) + if err != nil { + return err + } + + zap.S().Infof("Registered controller %s", util.MongoDbMultiReplicaSetController) + return err +} + +// OnDelete cleans up Ops Manager state and all Kubernetes resources associated with this instance. +func (r *ReconcileMongoDbMultiReplicaSet) OnDelete(obj runtime.Object, log *zap.SugaredLogger) error { + mrs := obj.(*mdbmultiv1.MongoDBMultiCluster) + return r.deleteManagedResources(*mrs, log) +} + +// cleanOpsManagerState removes the project configuration (processes, auth settings etc.) from the corresponding OM project. +func (r *ReconcileMongoDbMultiReplicaSet) cleanOpsManagerState(mrs mdbmultiv1.MongoDBMultiCluster, log *zap.SugaredLogger) error { + projectConfig, credsConfig, err := project.ReadConfigAndCredentials(r.client, r.SecretClient, &mrs, log) + if err != nil { + return err + } + + log.Infow("Removing replica set from Ops Manager", "config", mrs.Spec) + conn, err := connection.PrepareOpsManagerConnection(r.SecretClient, projectConfig, credsConfig, r.omConnectionFactory, mrs.Namespace, log) + if err != nil { + return err + } + + processNames := make([]string, 0) + err = conn.ReadUpdateDeployment( + func(d om.Deployment) error { + processNames = d.GetProcessNames(om.ReplicaSet{}, mrs.Name) + // error means that replica set is not in the deployment - it's ok and we can proceed (could happen if + // deletion cleanup happened twice and the first one cleaned OM state already) + if e := d.RemoveReplicaSetByName(mrs.Name, log); e != nil { + log.Warnf("Failed to remove replica set from automation config: %s", e) + } + + return nil + }, + log, + ) + if err != nil { + return err + } + + hostsToRemove, err := mrs.GetMultiClusterAgentHostnames() + if err != nil { + return err + } + log.Infow("Stop monitoring removed hosts in Ops Manager", "removedHosts", hostsToRemove) + + if err = host.StopMonitoring(conn, hostsToRemove, log); err != nil { + return err + } + + opts := authentication.Options{ + AuthoritativeSet: false, + ProcessNames: processNames, + } + + if err := authentication.Disable(conn, opts, true, log); err != nil { + return err + } + log.Infof("Removed deployment %s from Ops Manager at %s", mrs.Name, conn.BaseURL()) + return nil +} + +// deleteManagedResources deletes resources across all member clusters that are owned by this MongoDBMultiCluster resource. +func (r *ReconcileMongoDbMultiReplicaSet) deleteManagedResources(mrs mdbmultiv1.MongoDBMultiCluster, log *zap.SugaredLogger) error { + var errs error + if err := r.cleanOpsManagerState(mrs, log); err != nil { + errs = multierror.Append(errs, err) + } + + clusterSpecList, err := mrs.GetClusterSpecItems() + if err != nil { + return err + } + + for _, item := range clusterSpecList { + c := r.memberClusterClientsMap[item.ClusterName] + if err := r.deleteClusterResources(c, mrs, log); err != nil { + errs = multierror.Append(errs, xerrors.Errorf("failed deleting dependant resources in cluster %s: %w", item.ClusterName, err)) + } + } + return errs +} + +// deleteClusterResources removes all resources that are associated with the given MongoDBMultiCluster resource in a given cluster. +func (r *ReconcileMongoDbMultiReplicaSet) deleteClusterResources(c kubernetesClient.Client, mrs mdbmultiv1.MongoDBMultiCluster, log *zap.SugaredLogger) error { + var errs error + + // cleanup resources in the namespace as the MongoDBMultiCluster with the corresponding label. + cleanupOptions := mongodbCleanUpOptions{ + namespace: mrs.Namespace, + labels: mongoDBMultiLabels(mrs.Name, mrs.Namespace), + } + + if err := c.DeleteAllOf(context.TODO(), &corev1.Service{}, &cleanupOptions); err != nil { + errs = multierror.Append(errs, err) + } else { + log.Infof("Removed Serivces associated with %s/%s", mrs.Namespace, mrs.Name) + } + + if err := c.DeleteAllOf(context.TODO(), &appsv1.StatefulSet{}, &cleanupOptions); err != nil { + errs = multierror.Append(errs, err) + } else { + log.Infof("Removed StatefulSets associated with %s/%s", mrs.Namespace, mrs.Name) + } + + if err := c.DeleteAllOf(context.TODO(), &corev1.ConfigMap{}, &cleanupOptions); err != nil { + errs = multierror.Append(errs, err) + } else { + log.Infof("Removed ConfigMaps associated with %s/%s", mrs.Namespace, mrs.Name) + } + + if err := c.DeleteAllOf(context.TODO(), &corev1.Secret{}, &cleanupOptions); err != nil { + errs = multierror.Append(errs, err) + } else { + log.Infof("Removed Secrets associated with %s/%s", mrs.Namespace, mrs.Name) + } + + r.RemoveDependentWatchedResources(kube.ObjectKey(mrs.Namespace, mrs.Name)) + + return errs +} + +// filterClusterSpecItem filters items out of a list based on provided predicate. +func filterClusterSpecItem(items []mdbmultiv1.ClusterSpecItem, fn func(item mdbmultiv1.ClusterSpecItem) bool) []mdbmultiv1.ClusterSpecItem { + var result []mdbmultiv1.ClusterSpecItem + for _, item := range items { + if fn(item) { + result = append(result, item) + } + } + return result +} + +func sortClusterSpecList(clusterSpecList []mdbmultiv1.ClusterSpecItem) { + sort.SliceStable(clusterSpecList, func(i, j int) bool { + return clusterSpecList[i].ClusterName < clusterSpecList[j].ClusterName + }) +} + +func clusterSpecListsEqual(effective, desired []mdbmultiv1.ClusterSpecItem) bool { + comparer := cmp.Comparer(func(x, y automationconfig.MemberOptions) bool { + return true + }) + return cmp.Equal(effective, desired, comparer) +} diff --git a/controllers/operator/mongodbmultireplicaset_controller_test.go b/controllers/operator/mongodbmultireplicaset_controller_test.go new file mode 100644 index 000000000..3a7f7edb3 --- /dev/null +++ b/controllers/operator/mongodbmultireplicaset_controller_test.go @@ -0,0 +1,949 @@ +package operator + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "sort" + "testing" + + "k8s.io/apimachinery/pkg/types" + + "k8s.io/utils/pointer" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/watch" + "github.com/10gen/ops-manager-kubernetes/pkg/multicluster" + "github.com/10gen/ops-manager-kubernetes/pkg/multicluster/failedcluster" + "github.com/10gen/ops-manager-kubernetes/pkg/multicluster/memberwatch" + + appsv1 "k8s.io/api/apps/v1" + + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "github.com/google/uuid" + "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/api/v1/mdbmulti" + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/controllers/om/backup" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/mock" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/stretchr/testify/assert" + "sigs.k8s.io/controller-runtime/pkg/cluster" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +func init() { + logger, _ := zap.NewDevelopment() + zap.ReplaceGlobals(logger) +} + +var ( + clusters = []string{"api1.kube.com", "api2.kube.com", "api3.kube.com"} +) + +func checkMultiReconcileSuccessful(t *testing.T, reconciler reconcile.Reconciler, m *mdbmulti.MongoDBMultiCluster, client *mock.MockedClient, shouldRequeue bool) { + result, e := reconciler.Reconcile(context.TODO(), requestFromObject(m)) + assert.NoError(t, e) + if shouldRequeue { + assert.True(t, result.Requeue || result.RequeueAfter > 0) + } else { + assert.Equal(t, reconcile.Result{}, result) + } + + // fetch the last updates as the reconciliation loop can update the mdb resource. + err := client.Get(context.TODO(), kube.ObjectKey(m.Namespace, m.Name), m) + assert.NoError(t, err) +} + +func TestCreateMultiReplicaSet(t *testing.T) { + mrs := mdbmulti.DefaultMultiReplicaSetBuilder().SetClusterSpecList(clusters).Build() + + reconciler, client, _ := defaultMultiReplicaSetReconciler(mrs, t) + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + +} + +func TestReconcileFails_WhenProjectConfig_IsNotFound(t *testing.T) { + mrs := mdbmulti.DefaultMultiReplicaSetBuilder().Build() + + reconciler, client, _ := defaultMultiReplicaSetReconciler(mrs, t) + + err := client.DeleteConfigMap(kube.ObjectKey(mock.TestNamespace, mock.TestProjectConfigMapName)) + assert.NoError(t, err) + + result, err := reconciler.Reconcile(context.TODO(), requestFromObject(mrs)) + assert.Nil(t, err) + assert.True(t, result.RequeueAfter > 0) +} + +func TestMultiClusterConfigMapAndSecretWatched(t *testing.T) { + mrs := mdbmulti.DefaultMultiReplicaSetBuilder().SetClusterSpecList(clusters).Build() + + reconciler, client, _ := defaultMultiReplicaSetReconciler(mrs, t) + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + + expected := map[watch.Object][]types.NamespacedName{ + {ResourceType: watch.ConfigMap, Resource: kube.ObjectKey(mock.TestNamespace, mock.TestProjectConfigMapName)}: {kube.ObjectKey(mock.TestNamespace, mrs.Name)}, + {ResourceType: watch.Secret, Resource: kube.ObjectKey(mock.TestNamespace, mrs.Spec.Credentials)}: {kube.ObjectKey(mock.TestNamespace, mrs.Name)}, + } + + assert.Equal(t, reconciler.WatchedResources, expected) +} + +func TestServiceCreation_WithExternalName(t *testing.T) { + mrs := mdbmulti.DefaultMultiReplicaSetBuilder(). + SetClusterSpecList(clusters). + SetExternalAccess( + mdbv1.ExternalAccessConfiguration{ + ExternalDomain: pointer.String("cluster-%d.testing"), + }, "cluster-%d.testing"). + Build() + reconciler, client, memberClusterMap := defaultMultiReplicaSetReconciler(mrs, t) + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + + clusterSpecList, err := mrs.GetClusterSpecItems() + if err != nil { + assert.NoError(t, err) + } + clusterSpecs := clusterSpecList + for _, item := range clusterSpecs { + c := memberClusterMap[item.ClusterName] + for podNum := 0; podNum < item.Members; podNum++ { + externalService := getExternalService(mrs, item.ClusterName, podNum) + + err = c.GetClient().Get(context.TODO(), kube.ObjectKey(externalService.Namespace, externalService.Name), &corev1.Service{}) + assert.NoError(t, err) + + // ensure that all other clusters do not have this service + for _, otherItem := range clusterSpecs { + if item.ClusterName == otherItem.ClusterName { + continue + } + otherCluster := memberClusterMap[otherItem.ClusterName] + err = otherCluster.GetClient().Get(context.TODO(), kube.ObjectKey(externalService.Namespace, externalService.Name), &corev1.Service{}) + assert.Error(t, err) + } + } + } +} + +func TestServiceCreation_WithoutDuplicates(t *testing.T) { + mrs := mdbmulti.DefaultMultiReplicaSetBuilder(). + SetClusterSpecList(clusters). + Build() + reconciler, client, memberClusterMap := defaultMultiReplicaSetReconciler(mrs, t) + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + + clusterSpecList, err := mrs.GetClusterSpecItems() + if err != nil { + assert.NoError(t, err) + } + clusterSpecs := clusterSpecList + for _, item := range clusterSpecs { + c := memberClusterMap[item.ClusterName] + for podNum := 0; podNum < item.Members; podNum++ { + svc := getService(mrs, item.ClusterName, podNum) + + testSvc := corev1.Service{} + err := c.GetClient().Get(context.TODO(), kube.ObjectKey(svc.Namespace, svc.Name), &testSvc) + assert.NoError(t, err) + + // ensure that all other clusters do not have this service + for _, otherItem := range clusterSpecs { + if item.ClusterName == otherItem.ClusterName { + continue + } + otherCluster := memberClusterMap[otherItem.ClusterName] + err = otherCluster.GetClient().Get(context.TODO(), kube.ObjectKey(svc.Namespace, svc.Name), &corev1.Service{}) + assert.Error(t, err) + } + } + } +} + +func TestServiceCreation_WithDuplicates(t *testing.T) { + mrs := mdbmulti.DefaultMultiReplicaSetBuilder(). + SetClusterSpecList(clusters). + Build() + mrs.Spec.DuplicateServiceObjects = util.BooleanRef(true) + + reconciler, client, memberClusterMap := defaultMultiReplicaSetReconciler(mrs, t) + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + + clusterSpecs, err := mrs.GetClusterSpecItems() + if err != nil { + assert.NoError(t, err) + } + for _, item := range clusterSpecs { + for podNum := 0; podNum < item.Members; podNum++ { + svc := getService(mrs, item.ClusterName, podNum) + + // ensure that all clusters have all services + for _, otherItem := range clusterSpecs { + otherCluster := memberClusterMap[otherItem.ClusterName] + err := otherCluster.GetClient().Get(context.TODO(), kube.ObjectKey(svc.Namespace, svc.Name), &corev1.Service{}) + assert.NoError(t, err) + } + } + } +} + +func TestResourceDeletion(t *testing.T) { + mrs := mdbmulti.DefaultMultiReplicaSetBuilder().SetClusterSpecList(clusters).Build() + reconciler, client, memberClients := defaultMultiReplicaSetReconciler(mrs, t) + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + + t.Run("Resources are created", func(t *testing.T) { + clusterSpecs, err := mrs.GetClusterSpecItems() + if err != nil { + assert.NoError(t, err) + } + for _, item := range clusterSpecs { + c := memberClients[item.ClusterName] + t.Run("Stateful Set in each member cluster has been created", func(t *testing.T) { + sts := appsv1.StatefulSet{} + err := c.GetClient().Get(context.TODO(), kube.ObjectKey(mrs.Namespace, mrs.MultiStatefulsetName(mrs.ClusterNum(item.ClusterName))), &sts) + assert.NoError(t, err) + }) + + t.Run("Services in each member cluster have been created", func(t *testing.T) { + svcList := corev1.ServiceList{} + err := c.GetClient().List(context.TODO(), &svcList) + assert.NoError(t, err) + assert.Len(t, svcList.Items, item.Members+1) + }) + + t.Run("Configmaps in each member cluster have been created", func(t *testing.T) { + configMapList := corev1.ConfigMapList{} + err := c.GetClient().List(context.TODO(), &configMapList) + assert.NoError(t, err) + assert.Len(t, configMapList.Items, 1) + }) + t.Run("Secrets in each member cluster have been created", func(t *testing.T) { + secretList := corev1.SecretList{} + err := c.GetClient().List(context.TODO(), &secretList) + assert.NoError(t, err) + assert.Len(t, secretList.Items, 1) + }) + } + }) + + err := reconciler.deleteManagedResources(*mrs, zap.S()) + assert.NoError(t, err) + + clusterSpecs, err := mrs.GetClusterSpecItems() + if err != nil { + assert.NoError(t, err) + } + for _, item := range clusterSpecs { + c := memberClients[item.ClusterName] + t.Run("Stateful Set in each member cluster has been removed", func(t *testing.T) { + sts := appsv1.StatefulSet{} + err := c.GetClient().Get(context.TODO(), kube.ObjectKey(mrs.Namespace, mrs.MultiStatefulsetName(mrs.ClusterNum(item.ClusterName))), &sts) + assert.Error(t, err) + }) + + t.Run("Services in each member cluster have been removed", func(t *testing.T) { + svcList := corev1.ServiceList{} + err := c.GetClient().List(context.TODO(), &svcList) + assert.NoError(t, err) + assert.Len(t, svcList.Items, 0) + }) + + t.Run("Configmaps in each member cluster have been removed", func(t *testing.T) { + configMapList := corev1.ConfigMapList{} + err := c.GetClient().List(context.TODO(), &configMapList) + assert.NoError(t, err) + assert.Len(t, configMapList.Items, 0) + }) + + t.Run("Secrets in each member cluster have been removed", func(t *testing.T) { + secretList := corev1.SecretList{} + err := c.GetClient().List(context.TODO(), &secretList) + assert.NoError(t, err) + assert.Len(t, secretList.Items, 0) + }) + } + + t.Run("Ops Manager state has been cleaned", func(t *testing.T) { + processes := om.CurrMockedConnection.GetProcesses() + assert.Len(t, processes, 0) + + ac, err := om.CurrMockedConnection.ReadAutomationConfig() + assert.NoError(t, err) + + assert.Empty(t, ac.Auth.AutoAuthMechanisms) + assert.Empty(t, ac.Auth.DeploymentAuthMechanisms) + assert.False(t, ac.Auth.IsEnabled()) + }) + +} + +func TestGroupSecret_IsCopied_ToEveryMemberCluster(t *testing.T) { + mrs := mdbmulti.DefaultMultiReplicaSetBuilder().SetClusterSpecList(clusters).Build() + reconciler, client, memberClusterMap := defaultMultiReplicaSetReconciler(mrs, t) + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + + for _, clusterName := range clusters { + t.Run(fmt.Sprintf("Secret exists in cluster %s", clusterName), func(t *testing.T) { + c, ok := memberClusterMap[clusterName] + assert.True(t, ok) + + s := corev1.Secret{} + err := c.GetClient().Get(context.TODO(), kube.ObjectKey(mrs.Namespace, fmt.Sprintf("%s-group-secret", om.CurrMockedConnection.GroupID())), &s) + assert.NoError(t, err) + assert.Equal(t, mongoDBMultiLabels(mrs.Name, mrs.Namespace), s.Labels) + }) + } +} + +func TestAuthentication_IsEnabledInOM_WhenConfiguredInCR(t *testing.T) { + mrs := mdbmulti.DefaultMultiReplicaSetBuilder().SetSecurity(&mdbv1.Security{ + Authentication: &mdbv1.Authentication{Enabled: true, Modes: []string{"SCRAM"}}, + }).SetClusterSpecList(clusters).Build() + + reconciler, client, _ := defaultMultiReplicaSetReconciler(mrs, t) + + t.Run("Reconciliation is successful when configuring scram", func(t *testing.T) { + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + }) + + t.Run("Automation Config has been updated correctly", func(t *testing.T) { + ac, err := om.CurrMockedConnection.ReadAutomationConfig() + assert.NoError(t, err) + + assert.Contains(t, ac.Auth.AutoAuthMechanism, "SCRAM-SHA-256") + assert.Contains(t, ac.Auth.DeploymentAuthMechanisms, "SCRAM-SHA-256") + assert.True(t, ac.Auth.IsEnabled()) + assert.NotEmpty(t, ac.Auth.AutoPwd) + assert.NotEmpty(t, ac.Auth.Key) + assert.NotEmpty(t, ac.Auth.KeyFile) + assert.NotEmpty(t, ac.Auth.KeyFileWindows) + assert.NotEmpty(t, ac.Auth.AutoUser) + }) +} + +func TestTls_IsEnabledInOM_WhenConfiguredInCR(t *testing.T) { + mrs := mdbmulti.DefaultMultiReplicaSetBuilder().SetClusterSpecList(clusters).SetSecurity(&mdbv1.Security{ + TLSConfig: &mdbv1.TLSConfig{Enabled: true, CA: "some-ca"}, + CertificatesSecretsPrefix: "some-prefix", + }).Build() + + reconciler, client, _ := defaultMultiReplicaSetReconciler(mrs, t) + createMultiClusterReplicaSetTLSData(client, mrs, "some-ca") + + t.Run("Reconciliation is successful when configuring tls", func(t *testing.T) { + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + }) + + t.Run("Automation Config has been updated correctly", func(t *testing.T) { + processes := om.CurrMockedConnection.GetProcesses() + for _, p := range processes { + assert.True(t, p.IsTLSEnabled()) + assert.Equal(t, "requireTLS", p.TLSConfig()["mode"]) + } + }) +} + +func TestSpecIsSavedAsAnnotation_WhenReconciliationIsSuccessful(t *testing.T) { + mrs := mdbmulti.DefaultMultiReplicaSetBuilder().SetClusterSpecList(clusters).Build() + reconciler, client, _ := defaultMultiReplicaSetReconciler(mrs, t) + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + + //fetch the resource after reconciliation + err := client.Get(context.TODO(), kube.ObjectKey(mrs.Namespace, mrs.Name), mrs) + assert.NoError(t, err) + + expected := mrs.Spec + actual, err := mrs.ReadLastAchievedSpec() + assert.NoError(t, err) + assert.NotNil(t, actual) + + areEqual, err := specsAreEqual(expected, *actual) + + assert.NoError(t, err) + assert.True(t, areEqual) +} + +func TestScaling(t *testing.T) { + + t.Run("Can scale to max amount when creating the resource", func(t *testing.T) { + mrs := mdbmulti.DefaultMultiReplicaSetBuilder().SetClusterSpecList(clusters).Build() + reconciler, client, memberClusters := defaultMultiReplicaSetReconciler(mrs, t) + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + + statefulSets := readStatefulSets(mrs, memberClusters) + assert.Len(t, statefulSets, 3) + + clusterSpecs, err := mrs.GetClusterSpecItems() + if err != nil { + assert.NoError(t, err) + } + for _, item := range clusterSpecs { + sts := statefulSets[item.ClusterName] + assert.Equal(t, item.Members, int(*sts.Spec.Replicas)) + } + }) + + t.Run("Scale one at a time when scaling up", func(t *testing.T) { + mrs := mdbmulti.DefaultMultiReplicaSetBuilder().SetClusterSpecList(clusters).Build() + mrs.Spec.ClusterSpecList[0].Members = 1 + mrs.Spec.ClusterSpecList[1].Members = 1 + mrs.Spec.ClusterSpecList[2].Members = 1 + reconciler, client, memberClusters := defaultMultiReplicaSetReconciler(mrs, t) + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + statefulSets := readStatefulSets(mrs, memberClusters) + clusterSpecs, err := mrs.GetClusterSpecItems() + if err != nil { + assert.NoError(t, err) + } + for _, item := range clusterSpecs { + sts := statefulSets[item.ClusterName] + assert.Equal(t, 1, int(*sts.Spec.Replicas)) + } + + // scale up in two different clusters at once. + mrs.Spec.ClusterSpecList[0].Members = 3 + mrs.Spec.ClusterSpecList[2].Members = 3 + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, true) + assertStatefulSetReplicas(t, mrs, memberClusters, 2, 1, 1) + assert.Len(t, om.CurrMockedConnection.GetProcesses(), 4) + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, true) + assertStatefulSetReplicas(t, mrs, memberClusters, 3, 1, 1) + assert.Len(t, om.CurrMockedConnection.GetProcesses(), 5) + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, true) + assertStatefulSetReplicas(t, mrs, memberClusters, 3, 1, 2) + assert.Len(t, om.CurrMockedConnection.GetProcesses(), 6) + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + assertStatefulSetReplicas(t, mrs, memberClusters, 3, 1, 3) + assert.Len(t, om.CurrMockedConnection.GetProcesses(), 7) + }) + + t.Run("Scale one at a time when scaling down", func(t *testing.T) { + mrs := mdbmulti.DefaultMultiReplicaSetBuilder().SetClusterSpecList(clusters).Build() + mrs.Spec.ClusterSpecList[0].Members = 3 + mrs.Spec.ClusterSpecList[1].Members = 2 + mrs.Spec.ClusterSpecList[2].Members = 3 + reconciler, client, memberClusters := defaultMultiReplicaSetReconciler(mrs, t) + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + statefulSets := readStatefulSets(mrs, memberClusters) + clusterSpecList, err := mrs.GetClusterSpecItems() + if err != nil { + assert.NoError(t, err) + } + + for _, item := range clusterSpecList { + sts := statefulSets[item.ClusterName] + assert.Equal(t, item.Members, int(*sts.Spec.Replicas)) + } + + assert.Len(t, om.CurrMockedConnection.GetProcesses(), 8) + + // scale down in all clusters. + mrs.Spec.ClusterSpecList[0].Members = 1 + mrs.Spec.ClusterSpecList[1].Members = 1 + mrs.Spec.ClusterSpecList[2].Members = 1 + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, true) + assertStatefulSetReplicas(t, mrs, memberClusters, 2, 2, 3) + assert.Len(t, om.CurrMockedConnection.GetProcesses(), 7) + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, true) + assertStatefulSetReplicas(t, mrs, memberClusters, 1, 2, 3) + assert.Len(t, om.CurrMockedConnection.GetProcesses(), 6) + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, true) + assertStatefulSetReplicas(t, mrs, memberClusters, 1, 1, 3) + assert.Len(t, om.CurrMockedConnection.GetProcesses(), 5) + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, true) + assertStatefulSetReplicas(t, mrs, memberClusters, 1, 1, 2) + assert.Len(t, om.CurrMockedConnection.GetProcesses(), 4) + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + assertStatefulSetReplicas(t, mrs, memberClusters, 1, 1, 1) + assert.Len(t, om.CurrMockedConnection.GetProcesses(), 3) + }) + + t.Run("Added members don't have overlapping replica set member Ids", func(t *testing.T) { + mrs := mdbmulti.DefaultMultiReplicaSetBuilder().SetClusterSpecList(clusters).Build() + mrs.Spec.ClusterSpecList[0].Members = 1 + mrs.Spec.ClusterSpecList[1].Members = 1 + mrs.Spec.ClusterSpecList[2].Members = 1 + reconciler, client, _ := defaultMultiReplicaSetReconciler(mrs, t) + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + + assert.Len(t, om.CurrMockedConnection.GetProcesses(), 3) + + dep, err := om.CurrMockedConnection.ReadDeployment() + assert.NoError(t, err) + + replicaSets := dep.ReplicaSets() + + assert.Len(t, replicaSets, 1) + members := replicaSets[0].Members() + assert.Len(t, members, 3) + + assertMemberNameAndId(t, members, fmt.Sprintf("%s-0-0", mrs.Name), 0) + assertMemberNameAndId(t, members, fmt.Sprintf("%s-1-0", mrs.Name), 1) + assertMemberNameAndId(t, members, fmt.Sprintf("%s-2-0", mrs.Name), 2) + + assert.Equal(t, members[0].Id(), 0) + assert.Equal(t, members[1].Id(), 1) + assert.Equal(t, members[2].Id(), 2) + + mrs.Spec.ClusterSpecList[0].Members = 2 + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + + dep, err = om.CurrMockedConnection.ReadDeployment() + assert.NoError(t, err) + + replicaSets = dep.ReplicaSets() + + assert.Len(t, replicaSets, 1) + members = replicaSets[0].Members() + assert.Len(t, members, 4) + + assertMemberNameAndId(t, members, fmt.Sprintf("%s-0-0", mrs.Name), 0) + assertMemberNameAndId(t, members, fmt.Sprintf("%s-0-1", mrs.Name), 3) + assertMemberNameAndId(t, members, fmt.Sprintf("%s-1-0", mrs.Name), 1) + assertMemberNameAndId(t, members, fmt.Sprintf("%s-2-0", mrs.Name), 2) + }) + + t.Run("Cluster can be added", func(t *testing.T) { + mrs := mdbmulti.DefaultMultiReplicaSetBuilder().SetClusterSpecList(clusters).Build() + mrs.Spec.ClusterSpecList = mrs.Spec.ClusterSpecList[:len(mrs.Spec.ClusterSpecList)-1] + + mrs.Spec.ClusterSpecList[0].Members = 1 + mrs.Spec.ClusterSpecList[1].Members = 1 + + reconciler, client, memberClusters := defaultMultiReplicaSetReconciler(mrs, t) + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + + assertStatefulSetReplicas(t, mrs, memberClusters, 1, 1) + + // scale one member and add a new cluster + mrs.Spec.ClusterSpecList[0].Members = 3 + mrs.Spec.ClusterSpecList = append(mrs.Spec.ClusterSpecList, mdbmulti.ClusterSpecItem{ + ClusterName: clusters[2], + Members: 3, + }) + + err := client.Update(context.TODO(), mrs) + assert.NoError(t, err) + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, true) + assertStatefulSetReplicas(t, mrs, memberClusters, 2, 1, 0) + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, true) + assertStatefulSetReplicas(t, mrs, memberClusters, 3, 1, 0) + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, true) + assertStatefulSetReplicas(t, mrs, memberClusters, 3, 1, 1) + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, true) + assertStatefulSetReplicas(t, mrs, memberClusters, 3, 1, 2) + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + assertStatefulSetReplicas(t, mrs, memberClusters, 3, 1, 3) + }) + + t.Run("Cluster can be removed", func(t *testing.T) { + mrs := mdbmulti.DefaultMultiReplicaSetBuilder().SetClusterSpecList(clusters).Build() + + mrs.Spec.ClusterSpecList[0].Members = 3 + mrs.Spec.ClusterSpecList[1].Members = 2 + mrs.Spec.ClusterSpecList[2].Members = 3 + + reconciler, client, memberClusters := defaultMultiReplicaSetReconciler(mrs, t) + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + + assertStatefulSetReplicas(t, mrs, memberClusters, 3, 2, 3) + + mrs.Spec.ClusterSpecList[0].Members = 1 + mrs.Spec.ClusterSpecList = mrs.Spec.ClusterSpecList[:len(mrs.Spec.ClusterSpecList)-1] + + err := client.Update(context.TODO(), mrs) + assert.NoError(t, err) + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, true) + assertStatefulSetReplicas(t, mrs, memberClusters, 2, 2, 3) + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, true) + assertStatefulSetReplicas(t, mrs, memberClusters, 1, 2, 3) + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, true) + assertStatefulSetReplicas(t, mrs, memberClusters, 1, 2, 2) + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, true) + assertStatefulSetReplicas(t, mrs, memberClusters, 1, 2, 1) + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + assertStatefulSetReplicas(t, mrs, memberClusters, 1, 2) + + // can reconcile again and it succeeds. + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + assertStatefulSetReplicas(t, mrs, memberClusters, 1, 2) + }) + + t.Run("Multiple clusters can be removed", func(t *testing.T) { + mrs := mdbmulti.DefaultMultiReplicaSetBuilder().SetClusterSpecList(clusters).Build() + + mrs.Spec.ClusterSpecList[0].Members = 2 + mrs.Spec.ClusterSpecList[1].Members = 1 + mrs.Spec.ClusterSpecList[2].Members = 2 + + reconciler, client, memberClusters := defaultMultiReplicaSetReconciler(mrs, t) + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + + assertStatefulSetReplicas(t, mrs, memberClusters, 2, 1, 2) + + // remove first and last + mrs.Spec.ClusterSpecList = []mdbmulti.ClusterSpecItem{mrs.Spec.ClusterSpecList[1]} + + err := client.Update(context.TODO(), mrs) + assert.NoError(t, err) + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, true) + assertStatefulSetReplicas(t, mrs, memberClusters, 1, 1, 2) + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, true) + assertStatefulSetReplicas(t, mrs, memberClusters, 0, 1, 2) + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, true) + assertStatefulSetReplicas(t, mrs, memberClusters, 0, 1, 1) + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + assertStatefulSetReplicas(t, mrs, memberClusters, 0, 1, 0) + }) +} + +func TestClusterNumbering(t *testing.T) { + + t.Run("Create MDB CR first time", func(t *testing.T) { + mrs := mdbmulti.DefaultMultiReplicaSetBuilder().SetClusterSpecList(clusters).Build() + reconciler, client, _ := defaultMultiReplicaSetReconciler(mrs, t) + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + + clusterNumMap := getClusterNumMapping(mrs) + assertClusterpresent(t, clusterNumMap, mrs.Spec.ClusterSpecList, []int{0, 1, 2}) + }) + + t.Run("Add Cluster", func(t *testing.T) { + mrs := mdbmulti.DefaultMultiReplicaSetBuilder().SetClusterSpecList(clusters).Build() + mrs.Spec.ClusterSpecList = mrs.Spec.ClusterSpecList[:len(mrs.Spec.ClusterSpecList)-1] + + reconciler, client, _ := defaultMultiReplicaSetReconciler(mrs, t) + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + + clusterNumMap := getClusterNumMapping(mrs) + assertClusterpresent(t, clusterNumMap, mrs.Spec.ClusterSpecList, []int{0, 1}) + + // add cluster + mrs.Spec.ClusterSpecList = append(mrs.Spec.ClusterSpecList, mdbmulti.ClusterSpecItem{ + ClusterName: clusters[2], + Members: 1, + }) + + err := client.Update(context.TODO(), mrs) + assert.NoError(t, err) + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + clusterNumMap = getClusterNumMapping(mrs) + + assert.Equal(t, 2, clusterNumMap[clusters[2]]) + }) + + t.Run("Remove and Add back cluster", func(t *testing.T) { + mrs := mdbmulti.DefaultMultiReplicaSetBuilder().SetClusterSpecList(clusters).Build() + + mrs.Spec.ClusterSpecList[0].Members = 1 + mrs.Spec.ClusterSpecList[1].Members = 1 + mrs.Spec.ClusterSpecList[2].Members = 1 + + reconciler, client, _ := defaultMultiReplicaSetReconciler(mrs, t) + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + + clusterNumMap := getClusterNumMapping(mrs) + assertClusterpresent(t, clusterNumMap, mrs.Spec.ClusterSpecList, []int{0, 1, 2}) + clusterOneIndex := clusterNumMap[clusters[1]] + + // Remove cluster index 1 from the specs + mrs.Spec.ClusterSpecList = []mdbmulti.ClusterSpecItem{ + { + ClusterName: clusters[0], + Members: 1, + }, + { + ClusterName: clusters[2], + Members: 1, + }, + } + err := client.Update(context.TODO(), mrs) + assert.NoError(t, err) + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + + // Add cluster index 1 back to the specs + mrs.Spec.ClusterSpecList = append(mrs.Spec.ClusterSpecList, mdbmulti.ClusterSpecItem{ + ClusterName: clusters[1], + Members: 1, + }) + + err = client.Update(context.TODO(), mrs) + assert.NoError(t, err) + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + // assert the index corresponsing to cluster 1 is still 1 + clusterNumMap = getClusterNumMapping(mrs) + assert.Equal(t, clusterOneIndex, clusterNumMap[clusters[1]]) + }) +} + +func getClusterNumMapping(m *mdbmulti.MongoDBMultiCluster) map[string]int { + clusterMapping := make(map[string]int) + bytes := m.Annotations[mdbmulti.LastClusterNumMapping] + json.Unmarshal([]byte(bytes), &clusterMapping) + + return clusterMapping +} + +// assertMemberNameAndId makes sure that the member with the given name has the given id. +// the processes are sorted and the order in the automation config is not necessarily the order +// in which they appear in the CR. +func assertMemberNameAndId(t *testing.T, members []om.ReplicaSetMember, name string, id int) { + for _, m := range members { + if m.Name() == name { + assert.Equal(t, id, m.Id()) + return + } + } + t.Fatalf("Member with name %s not found in replica set members", name) +} + +func TestBackupConfigurationReplicaSet(t *testing.T) { + mrs := mdbmulti.DefaultMultiReplicaSetBuilder().SetClusterSpecList(clusters). + SetConnectionSpec(testConnectionSpec()). + SetBackup(mdbv1.Backup{ + Mode: "enabled", + }).Build() + + reconciler, client, _ := defaultMultiReplicaSetReconciler(mrs, t) + uuidStr := uuid.New().String() + + om.CurrMockedConnection = om.NewMockedOmConnection(om.NewDeployment()) + om.CurrMockedConnection.UpdateBackupConfig(&backup.Config{ + ClusterId: uuidStr, + Status: backup.Inactive, + }) + + // add the Replicaset cluster to OM + om.CurrMockedConnection.BackupHostClusters[uuidStr] = &backup.HostCluster{ + ReplicaSetName: mrs.Name, + ClusterName: mrs.Name, + TypeName: "REPLICA_SET", + } + + t.Run("Backup can be started", func(t *testing.T) { + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + configResponse, _ := om.CurrMockedConnection.ReadBackupConfigs() + + assert.Len(t, configResponse.Configs, 1) + config := configResponse.Configs[0] + + assert.Equal(t, backup.Started, config.Status) + assert.Equal(t, uuidStr, config.ClusterId) + assert.Equal(t, "PRIMARY", config.SyncSource) + }) + + t.Run("Backup snapshot schedule tests", backupSnapshotScheduleTests(mrs, client, reconciler, uuidStr)) + + t.Run("Backup can be stopped", func(t *testing.T) { + mrs.Spec.Backup.Mode = "disabled" + err := client.Update(context.TODO(), mrs) + assert.NoError(t, err) + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + + configResponse, _ := om.CurrMockedConnection.ReadBackupConfigs() + assert.Len(t, configResponse.Configs, 1) + + config := configResponse.Configs[0] + + assert.Equal(t, backup.Stopped, config.Status) + assert.Equal(t, uuidStr, config.ClusterId) + assert.Equal(t, "PRIMARY", config.SyncSource) + }) + + t.Run("Backup can be terminated", func(t *testing.T) { + mrs.Spec.Backup.Mode = "terminated" + err := client.Update(context.TODO(), mrs) + assert.NoError(t, err) + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + + configResponse, _ := om.CurrMockedConnection.ReadBackupConfigs() + assert.Len(t, configResponse.Configs, 1) + + config := configResponse.Configs[0] + + assert.Equal(t, backup.Terminating, config.Status) + assert.Equal(t, uuidStr, config.ClusterId) + assert.Equal(t, "PRIMARY", config.SyncSource) + }) +} + +func TestMultiClusterFailover(t *testing.T) { + mrs := mdbmulti.DefaultMultiReplicaSetBuilder().SetClusterSpecList(clusters).Build() + + reconciler, client, memberClusters := defaultMultiReplicaSetReconciler(mrs, t) + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + + // trigger failover by adding an annotation to the CR + // read the first cluster from the clusterSpec list and fail it over. + expectedNodeCount := 0 + for _, e := range mrs.Spec.ClusterSpecList { + expectedNodeCount += e.Members + } + + cluster := mrs.Spec.ClusterSpecList[0] + failedClusters := []failedcluster.FailedCluster{{ClusterName: cluster.ClusterName, Members: cluster.Members}} + + clusterSpecBytes, err := json.Marshal(failedClusters) + assert.NoError(t, err) + + mrs.SetAnnotations(map[string]string{failedcluster.FailedClusterAnnotation: string(clusterSpecBytes)}) + + err = client.Update(context.TODO(), mrs) + assert.NoError(t, err) + + os.Setenv("PERFORM_FAILOVER", "true") + defer os.Unsetenv("PERFORM_FAILOVER") + + memberwatch.AddFailoverAnnotation(*mrs, cluster.ClusterName, client) + + checkMultiReconcileSuccessful(t, reconciler, mrs, client, false) + + // assert the statefulset member count in the healthy cluster is same as the initial count + statefulSets := readStatefulSets(mrs, memberClusters) + currentNodeCount := 0 + + // only 2 clusters' statefulsets should be fetched since the first cluster has been failed-over + assert.Equal(t, 2, len(statefulSets)) + + for _, s := range statefulSets { + currentNodeCount += int(*s.Spec.Replicas) + } + + assert.Equal(t, expectedNodeCount, currentNodeCount) +} + +func assertClusterpresent(t *testing.T, m map[string]int, specs []mdbmulti.ClusterSpecItem, arr []int) { + tmp := make([]int, 0) + for _, s := range specs { + tmp = append(tmp, m[s.ClusterName]) + } + + sort.Ints(tmp) + assert.Equal(t, arr, tmp) +} + +func assertStatefulSetReplicas(t *testing.T, mrs *mdbmulti.MongoDBMultiCluster, memberClusters map[string]cluster.Cluster, expectedReplicas ...int) { + statefulSets := readStatefulSets(mrs, memberClusters) + + for i := range expectedReplicas { + if val, ok := statefulSets[clusters[i]]; ok { + assert.Equal(t, expectedReplicas[i], int(*val.Spec.Replicas)) + } + } +} + +func readStatefulSets(mrs *mdbmulti.MongoDBMultiCluster, memberClusters map[string]cluster.Cluster) map[string]appsv1.StatefulSet { + allStatefulSets := map[string]appsv1.StatefulSet{} + clusterSpecList, err := mrs.GetClusterSpecItems() + if err != nil { + panic(err) + } + + for _, item := range clusterSpecList { + memberClient := memberClusters[item.ClusterName] + sts := appsv1.StatefulSet{} + err := memberClient.GetClient().Get(context.TODO(), kube.ObjectKey(mrs.Namespace, mrs.MultiStatefulsetName(mrs.ClusterNum(item.ClusterName))), &sts) + if err == nil { + allStatefulSets[item.ClusterName] = sts + } + } + return allStatefulSets +} + +// specsAreEqual compares two different MongoDBMultiSpec instances and returns true if they are equal. +// the specs need to be marshaled and bytes compared as this ensures that empty slices are converted to nil +// ones and gives an accurate comparison. +// We are unable to use reflect.DeepEqual for this comparision as when deserialization happens, +// some fields on spec2 are nil, while spec1 are empty collections. By converting both to bytes +// we can ensure they are equivalent for our purposes. +func specsAreEqual(spec1, spec2 mdbmulti.MongoDBMultiSpec) (bool, error) { + spec1Bytes, err := json.Marshal(spec1) + if err != nil { + return false, err + } + spec2Bytes, err := json.Marshal(spec2) + if err != nil { + return false, err + } + return bytes.Equal(spec1Bytes, spec2Bytes), nil +} + +func defaultMultiReplicaSetReconciler(m *mdbmulti.MongoDBMultiCluster, t *testing.T) (*ReconcileMongoDbMultiReplicaSet, *mock.MockedClient, map[string]cluster.Cluster) { + connection := func(ctx *om.OMContext) om.Connection { + ret := om.NewEmptyMockedOmConnection(ctx) + ret.(*om.MockedOmConnection).Hostnames = calculateHostNames(m) + return ret + + } + return multiReplicaSetReconcilerWithConnection(m, connection, t) +} + +func calculateHostNames(m *mdbmulti.MongoDBMultiCluster) []string { + if m.Spec.ExternalAccessConfiguration == nil || m.Spec.ExternalAccessConfiguration.ExternalDomain == nil { + return nil + } + + var expectedHostnames []string + for i, cl := range m.Spec.ClusterSpecList { + for j := 0; j < cl.Members; j++ { + expectedHostnames = append(expectedHostnames, fmt.Sprintf("%s-%d-%d.%s", m.Name, i, j, *cl.ExternalAccessConfiguration.ExternalDomain)) + } + } + return expectedHostnames +} + +func multiReplicaSetReconcilerWithConnection(m *mdbmulti.MongoDBMultiCluster, + connectionFunc func(ctx *om.OMContext) om.Connection, t *testing.T) (*ReconcileMongoDbMultiReplicaSet, *mock.MockedClient, map[string]cluster.Cluster) { + manager := mock.NewManager(m) + manager.Client.AddDefaultMdbConfigResources() + memberClusterMap := getFakeMultiClusterMap() + return newMultiClusterReplicaSetReconciler(manager, connectionFunc, memberClusterMap), manager.Client, memberClusterMap +} + +func getFakeMultiClusterMap() map[string]cluster.Cluster { + clusterMap := make(map[string]cluster.Cluster) + + for _, e := range clusters { + memberClient := mock.NewClient() + memberCluster := multicluster.New(memberClient) + clusterMap[e] = memberCluster + } + return clusterMap +} diff --git a/controllers/operator/mongodbopsmanager_controller.go b/controllers/operator/mongodbopsmanager_controller.go new file mode 100644 index 000000000..569520c14 --- /dev/null +++ b/controllers/operator/mongodbopsmanager_controller.go @@ -0,0 +1,1698 @@ +package operator + +import ( + "context" + "crypto/sha256" + "encoding/base32" + "encoding/json" + "fmt" + "io/ioutil" + "math/rand" + "reflect" + "time" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/configmap" + "golang.org/x/xerrors" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/annotations" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/util/merge" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/authentication/scram" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/automationconfig" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/connectionstring" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/create" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/secrets" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/construct" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/apierror" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/secret" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/api/v1/mdbmulti" + omv1 "github.com/10gen/ops-manager-kubernetes/api/v1/om" + mdbstatus "github.com/10gen/ops-manager-kubernetes/api/v1/status" + "github.com/10gen/ops-manager-kubernetes/api/v1/user" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/agents" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/project" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/watch" + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" + "github.com/10gen/ops-manager-kubernetes/pkg/util/generate" + "github.com/10gen/ops-manager-kubernetes/pkg/util/identifiable" + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" + "github.com/10gen/ops-manager-kubernetes/pkg/util/versionutil" + "github.com/10gen/ops-manager-kubernetes/pkg/vault" + "github.com/10gen/ops-manager-kubernetes/pkg/vault/vaultwatcher" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/backup" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/workflow" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/controllers/om/api" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/blang/semver" + "go.uber.org/zap" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +const ( + oldestSupportedOpsManagerVersion = "5.0.0" + opsManagerToVersionMappingJsonFilePath = "/usr/local/om_version_mapping.json" // TODO: make that an envar to support local development + programmaticKeyVersion = "5.0.0" +) + +type S3ConfigGetter interface { + GetAuthenticationModes() []string + GetResourceName() string + BuildConnectionString(username, password string, scheme connectionstring.Scheme, connectionParams map[string]string) string +} + +type OpsManagerReconciler struct { + *ReconcileCommonController + omInitializer api.Initializer + omAdminProvider api.AdminProvider + omConnectionFactory om.ConnectionFactory + versionMappingProvider func(string) ([]byte, error) + oldestSupportedVersion semver.Version + programmaticKeyVersion semver.Version +} + +var _ reconcile.Reconciler = &OpsManagerReconciler{} + +func newOpsManagerReconciler(mgr manager.Manager, omFunc om.ConnectionFactory, initializer api.Initializer, adminProvider api.AdminProvider, versionMappingProvider func(string) ([]byte, error)) *OpsManagerReconciler { + return &OpsManagerReconciler{ + ReconcileCommonController: newReconcileCommonController(mgr), + omConnectionFactory: omFunc, + omInitializer: initializer, + omAdminProvider: adminProvider, + versionMappingProvider: versionMappingProvider, + oldestSupportedVersion: semver.MustParse(oldestSupportedOpsManagerVersion), + programmaticKeyVersion: semver.MustParse(programmaticKeyVersion), + } +} + +// +kubebuilder:rbac:groups=mongodb.com,resources={opsmanagers,opsmanagers/status,opsmanagers/finalizers},verbs=*,namespace=placeholder + +// Reconcile performs the reconciliation logic for AppDB, Ops Manager and Backup +// AppDB is reconciled first (independent of Ops Manager as the agent is run in headless mode) and +// Ops Manager statefulset is created then. +// Backup daemon statefulset is created/updated and configured optionally if backup is enabled. +// Note, that the pointer to ops manager resource is used in 'Reconcile' method as resource status is mutated +// many times during reconciliation, and It's important to keep updates to avoid status override +func (r *OpsManagerReconciler) Reconcile(ctx context.Context, request reconcile.Request) (res reconcile.Result, e error) { + log := zap.S().With("OpsManager", request.NamespacedName) + + opsManager := &omv1.MongoDBOpsManager{} + + opsManagerExtraStatusParams := mdbstatus.NewOMPartOption(mdbstatus.OpsManager) + + if reconcileResult, err := r.readOpsManagerResource(request, opsManager, log); err != nil { + if secrets.SecretNotExist(err) { + return reconcile.Result{}, nil + } + return reconcileResult, err + } + + log.Info("-> OpsManager.Reconcile") + log.Infow("OpsManager.Spec", "spec", opsManager.Spec) + log.Infow("OpsManager.Status", "status", opsManager.Status) + + // TODO: make SetupCommonWatchers support opsmanager watcher setup + // The order matters here, since appDB and opsManager share the same reconcile ObjectKey being opsmanager crd + // That means we need to remove first, which SetupCommonWatchers does, then register additional watches + appDBReplicaSet := opsManager.Spec.AppDB + r.SetupCommonWatchers(&appDBReplicaSet, nil, nil, appDBReplicaSet.Name()) + // We need to remove the watches on the top of the reconcile since we might add resources with the same key below. + if opsManager.IsTLSEnabled() { + r.RegisterWatchedTLSResources(opsManager.ObjectKey(), opsManager.GetSecurity().TLS.CA, []string{opsManager.TLSCertificateSecretName()}) + } + + // We perform this check here and not inside the validation because we don't want to put OM in failed state + // just log the error and put in the "Unsupported" state + semverVersion, err := versionutil.StringToSemverVersion(opsManager.Spec.Version) + if err != nil { + return r.updateStatus(opsManager, workflow.Invalid("%s is not a valid version", opsManager.Spec.Version), log, opsManagerExtraStatusParams) + } + if semverVersion.LT(r.oldestSupportedVersion) { + return r.updateStatus(opsManager, workflow.Unsupported("Ops Manager Version %s is not supported by this version of the operator. Please upgrade to a version >=%s", opsManager.Spec.Version, oldestSupportedOpsManagerVersion), log, opsManagerExtraStatusParams) + } + + // register backup + r.watchMongoDBResourcesReferencedByBackup(*opsManager, log) + + if err, part := opsManager.ProcessValidationsOnReconcile(); err != nil { + return r.updateStatus(opsManager, workflow.Invalid(err.Error()), log, mdbstatus.NewOMPartOption(part)) + } + + if err := ensureResourcesForArchitectureChange(r.SecretClient, *opsManager); err != nil { + return r.updateStatus(opsManager, workflow.Failed(xerrors.Errorf("Error ensuring resources for upgrade from 1 to 3 container AppDB: %w", err)), log, opsManagerExtraStatusParams) + } + + if err := ensureSharedGlobalResources(r.client, *opsManager); err != nil { + return r.updateStatus(opsManager, workflow.Failed(xerrors.Errorf("Error ensuring shared global resources %w", err)), log, opsManagerExtraStatusParams) + } + + opsManagerUserPassword, err := r.ensureAppDbPassword(*opsManager, log) + + if err != nil { + return r.updateStatus(opsManager, workflow.Failed(xerrors.Errorf("Error ensuring Ops Manager user password: %w", err)), log, opsManagerExtraStatusParams) + } + + // 1. Reconcile AppDB + emptyResult := reconcile.Result{} + retryResult := reconcile.Result{Requeue: true} + appDbReconciler := newAppDBReplicaSetReconciler(r.ReconcileCommonController, r.omConnectionFactory, r.versionMappingProvider) + result, err := appDbReconciler.ReconcileAppDB(opsManager, opsManagerUserPassword) + if err != nil || (result != emptyResult && result != retryResult) { + return result, err + } + + // 2. Reconcile Ops Manager + status, omAdmin := r.reconcileOpsManager(opsManager, opsManagerUserPassword, log) + if !status.IsOK() { + return r.updateStatus(opsManager, status, log, opsManagerExtraStatusParams, mdbstatus.NewBaseUrlOption(opsManager.CentralURL())) + } + + // the AppDB still needs to configure monitoring, now that Ops Manager has been created + // we can finish this configuration. + if result.Requeue { + log.Infof("Requeuing reconciliation to configure AppDB monitoring in Ops Manager.") + return result, nil + } + + // 3. Reconcile Backup Daemon + if status := r.reconcileBackupDaemon(opsManager, omAdmin, opsManagerUserPassword, log); !status.IsOK() { + return r.updateStatus(opsManager, status, log, mdbstatus.NewOMPartOption(mdbstatus.Backup)) + } + + annotationsToAdd, err := getAnnotationsForOpsManagerResource(opsManager) + if err != nil { + return r.updateStatus(opsManager, workflow.Failed(err), log) + } + + if vault.IsVaultSecretBackend() { + vaultMap := make(map[string]string) + for _, s := range opsManager.GetSecretsMountedIntoPod() { + path := fmt.Sprintf("%s/%s/%s", r.VaultClient.OpsManagerSecretMetadataPath(), appDBReplicaSet.Namespace, s) + vaultMap = merge.StringToStringMap(vaultMap, r.VaultClient.GetSecretAnnotation(path)) + } + for _, s := range opsManager.Spec.AppDB.GetSecretsMountedIntoPod() { + path := fmt.Sprintf("%s/%s/%s", r.VaultClient.AppDBSecretMetadataPath(), appDBReplicaSet.Namespace, s) + vaultMap = merge.StringToStringMap(vaultMap, r.VaultClient.GetSecretAnnotation(path)) + } + + for k, val := range vaultMap { + annotationsToAdd[k] = val + } + } + + if err := annotations.SetAnnotations(opsManager, annotationsToAdd, r.client); err != nil { + return r.updateStatus(opsManager, workflow.Failed(err), log) + } + // All statuses are updated by now - we don't need to update any others - just return + log.Info("Finished reconciliation for MongoDbOpsManager!") + // success + return reconcile.Result{}, nil +} + +// getMonitoringAgentVersion returns the minimum supported agent version for the given version of Ops Manager. +func getMonitoringAgentVersion(opsManager omv1.MongoDBOpsManager, readFile func(filename string) ([]byte, error)) (string, error) { + version, err := versionutil.StringToSemverVersion(opsManager.Spec.Version) + if err != nil { + return "", xerrors.Errorf("failed extracting semver version from Ops Manager version %s: %w", opsManager.Spec.Version, err) + } + + majorMinor := fmt.Sprintf("%d.%d", version.Major, version.Minor) + fileContainingMappingsBytes, err := readFile(opsManagerToVersionMappingJsonFilePath) + if err != nil { + return "", xerrors.Errorf("failed reading file %s: %w", opsManagerToVersionMappingJsonFilePath, err) + } + + // no bytes but no error, with an empty string we will use the same version as automation agent. + if fileContainingMappingsBytes == nil { + return "", nil + } + + m := omv1.OpsManagerAgentVersionMapping{} + if err := json.Unmarshal(fileContainingMappingsBytes, &m); err != nil { + return "", xerrors.Errorf("failed unmarshalling bytes: %w", err) + } + + agentVersion := m.FindAgentVersionForOpsManager(majorMinor) + if agentVersion == "" { + return "", xerrors.Errorf("agent version not present in the mapping file %s", opsManagerToVersionMappingJsonFilePath) + } else { + return agentVersion, nil + } +} + +// ensureSharedGlobalResources ensures that resources that are shared across watched namespaces (e.g. secrets) are in sync +func ensureSharedGlobalResources(secretGetUpdaterCreator secret.GetUpdateCreator, opsManager omv1.MongoDBOpsManager) error { + operatorNamespace := env.ReadOrPanic(util.CurrentNamespace) + if operatorNamespace == opsManager.Namespace { + // nothing to sync, OM runs in the same namespace as the operator + return nil + } + + if imagePullSecretsName, found := env.Read(util.ImagePullSecrets); found { + imagePullSecrets, err := secretGetUpdaterCreator.GetSecret(kube.ObjectKey(operatorNamespace, imagePullSecretsName)) + if err != nil { + return err + } + + omNsSecret := secret.Builder(). + SetName(imagePullSecretsName). + SetNamespace(opsManager.Namespace). + SetByteData(imagePullSecrets.Data). + Build() + omNsSecret.Type = imagePullSecrets.Type + if err := createOrUpdateSecretIfNotFound(secretGetUpdaterCreator, omNsSecret); err != nil { + return err + } + } + + return nil +} + +// ensureResourcesForArchitectureChange ensures that the new resources expected to be present. +func ensureResourcesForArchitectureChange(secretGetUpdaterCreator secret.GetUpdateCreator, opsManager omv1.MongoDBOpsManager) error { + acSecret, err := secretGetUpdaterCreator.GetSecret(kube.ObjectKey(opsManager.Namespace, opsManager.Spec.AppDB.AutomationConfigSecretName())) + + // if the automation config does not exist, we are not upgrading from an existing deployment. We can create everything from scratch. + if err != nil { + if !secrets.SecretNotExist(err) { + return xerrors.Errorf("error getting existing automation config secret: %w", err) + } + return nil + } + + ac, err := automationconfig.FromBytes(acSecret.Data[automationconfig.ConfigKey]) + if err != nil { + return xerrors.Errorf("error unmarshalling existing automation: %w", err) + } + + // the Ops Manager user should always exist within the automation config. + var omUser automationconfig.MongoDBUser + for _, user := range ac.Auth.Users { + if user.Username == util.OpsManagerMongoDBUserName { + omUser = user + break + } + } + + if omUser.Username == "" { + return xerrors.Errorf("ops manager user not present in the automation config") + } + + err = createOrUpdateSecretIfNotFound(secretGetUpdaterCreator, secret.Builder(). + SetName(opsManager.Spec.AppDB.OpsManagerUserScramCredentialsName()). + SetNamespace(opsManager.Namespace). + SetField("sha1-salt", omUser.ScramSha1Creds.Salt). + SetField("sha-1-server-key", omUser.ScramSha1Creds.ServerKey). + SetField("sha-1-stored-key", omUser.ScramSha1Creds.StoredKey). + SetField("sha256-salt", omUser.ScramSha256Creds.Salt). + SetField("sha-256-server-key", omUser.ScramSha256Creds.ServerKey). + SetField("sha-256-stored-key", omUser.ScramSha256Creds.StoredKey). + Build(), + ) + if err != nil { + return xerrors.Errorf("failed to create/update scram crdentials secret for Ops Manager user: %w", err) + } + + // ensure that the agent password stays consistent with what it was previously + err = createOrUpdateSecretIfNotFound(secretGetUpdaterCreator, secret.Builder(). + SetName(opsManager.Spec.AppDB.GetAgentPasswordSecretNamespacedName().Name). + SetNamespace(opsManager.Spec.AppDB.GetAgentPasswordSecretNamespacedName().Namespace). + SetField(scram.AgentPasswordKey, ac.Auth.AutoPwd). + Build(), + ) + if err != nil { + return xerrors.Errorf("failed to create/update password secret for agent user: %w", err) + } + + // ensure that the keyfile stays consistent with what it was previously + err = createOrUpdateSecretIfNotFound(secretGetUpdaterCreator, secret.Builder(). + SetName(opsManager.Spec.AppDB.GetAgentKeyfileSecretNamespacedName().Name). + SetNamespace(opsManager.Spec.AppDB.GetAgentKeyfileSecretNamespacedName().Namespace). + SetField(scram.AgentKeyfileKey, ac.Auth.Key). + Build(), + ) + + if err != nil { + return xerrors.Errorf("failed to create/update keyfile secret for agent user: %w", err) + } + + // there was a rename for a specific secret, `om-resource-db-password -> om-resource-db-om-password` + // this was done as now there are multiple secrets associated with the AppDB, and the contents of this old one correspond to the Ops Manager user. + oldOpsManagerUserPasswordSecret, err := secretGetUpdaterCreator.GetSecret(kube.ObjectKey(opsManager.Namespace, opsManager.Spec.AppDB.Name()+"-password")) + if err != nil { + // if it's not there, we don't want to create it. We only want to create the new secret if it is present. + if secrets.SecretNotExist(err) { + return nil + } + return err + } + + return secret.CreateOrUpdate(secretGetUpdaterCreator, secret.Builder(). + SetNamespace(opsManager.Namespace). + SetName(opsManager.Spec.AppDB.GetOpsManagerUserPasswordSecretName()). + SetByteData(oldOpsManagerUserPasswordSecret.Data). + Build(), + ) +} + +// createOrUpdateSecretIfNotFound creates the given secret if it does not exist. +func createOrUpdateSecretIfNotFound(secretGetUpdaterCreator secret.GetUpdateCreator, desiredSecret corev1.Secret) error { + _, err := secretGetUpdaterCreator.GetSecret(kube.ObjectKey(desiredSecret.Namespace, desiredSecret.Name)) + if err != nil { + if secrets.SecretNotExist(err) { + return secret.CreateOrUpdate(secretGetUpdaterCreator, desiredSecret) + } + return xerrors.Errorf("error getting secret %s/%s: %w", desiredSecret.Namespace, desiredSecret.Name, err) + } + return nil + +} + +func (r *OpsManagerReconciler) reconcileOpsManager(opsManager *omv1.MongoDBOpsManager, opsManagerUserPassword string, log *zap.SugaredLogger) (workflow.Status, api.OpsManagerAdmin) { + statusOptions := []mdbstatus.Option{mdbstatus.NewOMPartOption(mdbstatus.OpsManager), mdbstatus.NewBaseUrlOption(opsManager.CentralURL())} + + _, err := r.updateStatus(opsManager, workflow.Reconciling(), log, statusOptions...) + if err != nil { + return workflow.Failed(err), nil + } + + // Prepare Ops Manager StatefulSet (create and wait) + status := r.createOpsManagerStatefulset(*opsManager, opsManagerUserPassword, log) + if !status.IsOK() { + return status, nil + } + + // 3. Prepare Ops Manager (ensure the first user is created and public API key saved to secret) + var omAdmin api.OpsManagerAdmin + if status, omAdmin = r.prepareOpsManager(*opsManager, log); !status.IsOK() { + return status, nil + } + + // 4. Trigger agents upgrade if necessary + if err = triggerOmChangedEventIfNeeded(*opsManager, log); err != nil { + log.Warn("Not triggering an Ops Manager version changed event: %s", err) + } + + // 5. Stop backup daemon if necessary + if err = r.stopBackupDaemonIfNeeded(*opsManager); err != nil { + return workflow.Failed(err), nil + } + + if _, err = r.updateStatus(opsManager, workflow.OK(), log, statusOptions...); err != nil { + return workflow.Failed(err), nil + } + + return status, omAdmin +} + +// triggerOmChangedEventIfNeeded triggers upgrade process for all the MongoDB agents in the system if the major/minor version upgrade +// happened for Ops Manager +func triggerOmChangedEventIfNeeded(opsManager omv1.MongoDBOpsManager, log *zap.SugaredLogger) error { + if opsManager.Spec.Version == opsManager.Status.OpsManagerStatus.Version || opsManager.Status.OpsManagerStatus.Version == "" { + return nil + } + newVersion, err := versionutil.StringToSemverVersion(opsManager.Spec.Version) + if err != nil { + return xerrors.Errorf("failed to parse Ops Manager version %s: %w", opsManager.Spec.Version, err) + } + oldVersion, err := versionutil.StringToSemverVersion(opsManager.Status.OpsManagerStatus.Version) + if err != nil { + return xerrors.Errorf("failed to parse Ops Manager status version %s: %w", opsManager.Status.OpsManagerStatus.Version, err) + } + if newVersion.Major != oldVersion.Major || newVersion.Minor != oldVersion.Minor { + log.Infof("Ops Manager version has upgraded from %s to %s - scheduling the upgrade for all the Agents in the system", oldVersion, newVersion) + agents.ScheduleUpgrade() + } + + return nil +} + +// stopBackupDaemonIfNeeded stops the backup daemon when OM is upgraded. +// Otherwise, the backup daemon will remain in a broken state (because of version missmatch between OM and backup daemon) +// due to this STS limitation: https://github.com/kubernetes/kubernetes/issues/67250. +// Later, the normal reconcile process will update the STS and start the backup daemon. +func (r *OpsManagerReconciler) stopBackupDaemonIfNeeded(opsManager omv1.MongoDBOpsManager) error { + if opsManager.Spec.Version == opsManager.Status.OpsManagerStatus.Version || opsManager.Status.OpsManagerStatus.Version == "" { + return nil + } + + if _, err := r.scaleStatefulSet(opsManager.Namespace, opsManager.BackupStatefulSetName(), 0); err != nil { + return client.IgnoreNotFound(err) + } + + // delete all backup daemon pods, scaling down the statefulSet to 0 does not terminate the pods, + // if the number of pods is greater than 1 and all of them are in a unhealthy state + cleanupOptions := mongodbCleanUpOptions{ + namespace: opsManager.Namespace, + labels: map[string]string{ + "app": opsManager.BackupServiceName(), + }, + } + err := r.client.DeleteAllOf(context.TODO(), &corev1.Pod{}, &cleanupOptions) + + return client.IgnoreNotFound(err) +} + +func (r *OpsManagerReconciler) reconcileBackupDaemon(opsManager *omv1.MongoDBOpsManager, omAdmin api.OpsManagerAdmin, opsManagerUserPassword string, log *zap.SugaredLogger) workflow.Status { + backupStatusPartOption := mdbstatus.NewOMPartOption(mdbstatus.Backup) + + // If backup is not enabled, we check whether it is still configured in OM to update the status. + if !opsManager.Spec.Backup.Enabled { + var backupStatus workflow.Status + backupStatus = workflow.OK() + + for _, hostName := range opsManager.BackupDaemonFQDNs() { + _, err := omAdmin.ReadDaemonConfig(hostName, util.PvcMountPathHeadDb) + if apierror.NewNonNil(err).ErrorCode == apierror.BackupDaemonConfigNotFound { + backupStatus = workflow.Disabled() + break + } + } + + _, err := r.updateStatus(opsManager, backupStatus, log, backupStatusPartOption) + if err != nil { + return workflow.Failed(err) + } + return backupStatus + } + _, err := r.updateStatus(opsManager, workflow.Reconciling(), log, backupStatusPartOption) + if err != nil { + return workflow.Failed(err) + } + + // Prepare Backup Daemon StatefulSet (create and wait) + if status := r.createBackupDaemonStatefulset(*opsManager, opsManagerUserPassword, log); !status.IsOK() { + return status + } + + // Configure Backup using API + if status := r.prepareBackupInOpsManager(*opsManager, omAdmin, log); !status.IsOK() { + return status + } + + // StatefulSet will reach ready state eventually once backup has been configured in Ops Manager. + if status := getStatefulSetStatus(opsManager.Namespace, opsManager.BackupStatefulSetName(), r.client); !status.IsOK() { + return status + } + + if _, err := r.updateStatus(opsManager, workflow.OK(), log, backupStatusPartOption); err != nil { + return workflow.Failed(err) + } + + return workflow.OK() +} + +// readOpsManagerResource reads Ops Manager Custom resource into pointer provided +func (r *OpsManagerReconciler) readOpsManagerResource(request reconcile.Request, ref *omv1.MongoDBOpsManager, log *zap.SugaredLogger) (reconcile.Result, error) { + if result, err := r.getResource(request, ref, log); err != nil { + return result, err + } + // Reset warnings so that they are not stale, will populate accurate warnings in reconciliation + ref.SetWarnings([]mdbstatus.Warning{}, mdbstatus.NewOMPartOption(mdbstatus.OpsManager), mdbstatus.NewOMPartOption(mdbstatus.AppDb), mdbstatus.NewOMPartOption(mdbstatus.Backup)) + return reconcile.Result{}, nil +} + +// ensureAppDBConnectionString ensures that the AppDB Connection String exists in a secret. +func (r *OpsManagerReconciler) ensureAppDBConnectionString(opsManager omv1.MongoDBOpsManager, computedConnectionString string, log *zap.SugaredLogger) error { + var opsManagerSecretPath string + if r.VaultClient != nil { + opsManagerSecretPath = r.VaultClient.OpsManagerSecretPath() + } + _, err := r.ReadSecret(kube.ObjectKey(opsManager.Namespace, opsManager.AppDBMongoConnectionStringSecretName()), opsManagerSecretPath) + + if err != nil { + if secrets.SecretNotExist(err) { + log.Debugf("AppDB connection string secret was not found, creating %s now", kube.ObjectKey(opsManager.Namespace, opsManager.AppDBMongoConnectionStringSecretName())) + // assume the secret was not found, need to create it + + connectionStringSecret := secret.Builder(). + SetName(opsManager.AppDBMongoConnectionStringSecretName()). + SetNamespace(opsManager.Namespace). + SetField(util.AppDbConnectionStringKey, computedConnectionString). + Build() + + return r.PutSecret(connectionStringSecret, opsManagerSecretPath) + } + log.Warnf("Error getting connection string secret: %s", err) + return err + } + + connectionStringSecretData := map[string]string{ + util.AppDbConnectionStringKey: computedConnectionString, + } + connectionStringSecret := secret.Builder(). + SetName(opsManager.AppDBMongoConnectionStringSecretName()). + SetNamespace(opsManager.Namespace). + SetStringMapToData(connectionStringSecretData).Build() + log.Debugf("Connection string secret already exists, updating %s", kube.ObjectKey(opsManager.Namespace, opsManager.AppDBMongoConnectionStringSecretName())) + return r.PutSecret(connectionStringSecret, opsManagerSecretPath) +} + +func hashConnectionString(connectionString string) string { + bytes := []byte(connectionString) + hashBytes := sha256.Sum256(bytes) + return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(hashBytes[:]) +} + +// createOpsManagerStatefulset ensures the gen key secret exists and creates the Ops Manager StatefulSet. +func (r *OpsManagerReconciler) createOpsManagerStatefulset(opsManager omv1.MongoDBOpsManager, opsManagerUserPassword string, log *zap.SugaredLogger) workflow.Status { + if err := r.ensureGenKey(opsManager, log); err != nil { + return workflow.Failed(err) + } + + connectionString := buildMongoConnectionUrl(opsManager, opsManagerUserPassword) + if err := r.ensureAppDBConnectionString(opsManager, connectionString, log); err != nil { + return workflow.Failed(err) + } + + r.ensureConfiguration(&opsManager, log) + + var vaultConfig vault.VaultConfiguration + if r.VaultClient != nil { + vaultConfig = r.VaultClient.VaultConfig + } + sts, err := construct.OpsManagerStatefulSet(r.SecretClient, opsManager, log, + construct.WithConnectionStringHash(hashConnectionString(connectionString)), + construct.WithVaultConfig(vaultConfig), + construct.WithKmipConfig(opsManager, r.client, log), + ) + + if err != nil { + return workflow.Failed(xerrors.Errorf("error building OpsManager stateful set: %w", err)) + } + + if err := create.OpsManagerInKubernetes(r.client, opsManager, sts, log); err != nil { + return workflow.Failed(err) + } + + if status := getStatefulSetStatus(opsManager.Namespace, opsManager.Name, r.client); !status.IsOK() { + return status + } + + return workflow.OK() +} + +func AddOpsManagerController(mgr manager.Manager) error { + reconciler := newOpsManagerReconciler(mgr, om.NewOpsManagerConnection, &api.DefaultInitializer{}, api.NewOmAdmin, ioutil.ReadFile) + c, err := controller.New(util.MongoDbOpsManagerController, mgr, controller.Options{Reconciler: reconciler}) + if err != nil { + return err + } + + // watch for changes to the Ops Manager resources + eventHandler := MongoDBOpsManagerEventHandler{reconciler: reconciler} + + if err = c.Watch(&source.Kind{Type: &omv1.MongoDBOpsManager{}}, &eventHandler, watch.PredicatesForOpsManager()); err != nil { + return err + } + + // watch the secret with the Ops Manager user password + err = c.Watch(&source.Kind{Type: &corev1.Secret{}}, + &watch.ResourcesHandler{ResourceType: watch.Secret, TrackedResources: reconciler.WatchedResources}) + if err != nil { + return err + } + + err = c.Watch(&source.Kind{Type: &corev1.Secret{}}, + &watch.ResourcesHandler{ResourceType: watch.ConfigMap, TrackedResources: reconciler.WatchedResources}) + if err != nil { + return err + } + + err = c.Watch(&source.Kind{Type: &mdbv1.MongoDB{}}, + &watch.ResourcesHandler{ResourceType: watch.MongoDB, TrackedResources: reconciler.WatchedResources}) + if err != nil { + return err + } + + // if vault secret backend is enabled watch for Vault secret change and trigger reconcile + if vault.IsVaultSecretBackend() { + eventChannel := make(chan event.GenericEvent) + go vaultwatcher.WatchSecretChangeForOM(zap.S(), eventChannel, reconciler.client, reconciler.VaultClient) + + err = c.Watch( + &source.Channel{Source: eventChannel}, + &handler.EnqueueRequestForObject{}, + ) + if err != nil { + zap.S().Errorf("Failed to watch for vault secret changes: %w", err) + } + } + zap.S().Infof("Registered controller %s", util.MongoDbOpsManagerController) + return nil +} + +// ensureConfiguration makes sure the mandatory configuration is specified. +func (r OpsManagerReconciler) ensureConfiguration(opsManager *omv1.MongoDBOpsManager, log *zap.SugaredLogger) { + // update the central URL + setConfigProperty(opsManager, util.MmsCentralUrlPropKey, opsManager.CentralURL(), log) + + if opsManager.Spec.AppDB.Security.IsTLSEnabled() { + setConfigProperty(opsManager, util.MmsMongoSSL, "true", log) + } + if opsManager.Spec.AppDB.GetCAConfigMapName() != "" { + setConfigProperty(opsManager, util.MmsMongoCA, util.AppDBMmsCaFileDirInContainer+"ca-pem", log) + } + + // override the versions directory (defaults to "/opt/mongodb/mms/mongodb-releases/") + setConfigProperty(opsManager, util.MmsVersionsDirectory, "/mongodb-ops-manager/mongodb-releases/", log) + + // feature controls will always be enabled + setConfigProperty(opsManager, util.MmsFeatureControls, "true", log) + + if opsManager.Spec.Backup.QueryableBackupSecretRef.Name != "" { + setConfigProperty(opsManager, util.BrsQueryablePem, "/certs/queryable.pem", log) + } +} + +// createBackupDaemonStatefulset creates a StatefulSet for backup daemon and waits shortly until it's started +// Note, that the idea of creating two statefulsets for Ops Manager and Backup Daemon in parallel hasn't worked out +// as the daemon in this case just hangs silently (in practice it's ok to start it in ~1 min after start of OM though +// we will just start them sequentially) +func (r *OpsManagerReconciler) createBackupDaemonStatefulset(opsManager omv1.MongoDBOpsManager, + opsManagerUserPassword string, log *zap.SugaredLogger) workflow.Status { + if !opsManager.Spec.Backup.Enabled { + return workflow.OK() + } + connectionString := buildMongoConnectionUrl(opsManager, opsManagerUserPassword) + if err := r.ensureAppDBConnectionString(opsManager, connectionString, log); err != nil { + return workflow.Failed(err) + } + + r.ensureConfiguration(&opsManager, log) + + var vaultConfig vault.VaultConfiguration + if r.VaultClient != nil { + vaultConfig = r.VaultClient.VaultConfig + } + sts, err := construct.BackupDaemonStatefulSet(r.SecretClient, opsManager, log, + construct.WithConnectionStringHash(hashConnectionString(connectionString)), + construct.WithVaultConfig(vaultConfig), + construct.WithKmipConfig(opsManager, r.client, log)) + if err != nil { + return workflow.Failed(xerrors.Errorf("error building stateful set: %w", err)) + } + + needToRequeue, err := create.BackupDaemonInKubernetes(r.client, opsManager, sts, log) + if err != nil { + return workflow.Failed(err) + } + if needToRequeue { + return workflow.OK().Requeue() + } + return workflow.OK() +} + +func (r *OpsManagerReconciler) watchMongoDBResourcesReferencedByKmip(opsManager omv1.MongoDBOpsManager, log *zap.SugaredLogger) { + if !opsManager.Spec.IsKmipEnabled() { + return + } + + mdbList := &mdbv1.MongoDBList{} + err := r.client.List(context.TODO(), mdbList) + if err != nil { + log.Warnf("failed to fetch MongoDBList from Kubernetes: %v", err) + } + + for _, m := range mdbList.Items { + if m.Spec.Backup != nil && m.Spec.Backup.IsKmipEnabled() { + r.AddWatchedResourceIfNotAdded( + m.Name, + m.Namespace, + watch.MongoDB, + kube.ObjectKeyFromApiObject(&opsManager)) + + r.AddWatchedResourceIfNotAdded( + m.Spec.Backup.Encryption.Kmip.Client.ClientCertificateSecretName(m.GetName()), + opsManager.Namespace, + watch.Secret, + kube.ObjectKeyFromApiObject(&opsManager)) + + r.AddWatchedResourceIfNotAdded( + m.Spec.Backup.Encryption.Kmip.Client.ClientCertificatePasswordSecretName(m.GetName()), + opsManager.Namespace, + watch.Secret, + kube.ObjectKeyFromApiObject(&opsManager)) + } + } +} + +func (r *OpsManagerReconciler) watchCaReferencedByKmip(opsManager omv1.MongoDBOpsManager) { + if !opsManager.Spec.IsKmipEnabled() { + return + } + + r.AddWatchedResourceIfNotAdded( + opsManager.Spec.Backup.Encryption.Kmip.Server.CA, + opsManager.Namespace, + watch.ConfigMap, + kube.ObjectKeyFromApiObject(&opsManager)) +} + +func (r *OpsManagerReconciler) watchMongoDBResourcesReferencedByBackup(opsManager omv1.MongoDBOpsManager, log *zap.SugaredLogger) { + if !opsManager.Spec.Backup.Enabled { + return + } + + // watch mongodb resources for oplog + oplogs := opsManager.Spec.Backup.OplogStoreConfigs + for _, oplogConfig := range oplogs { + r.AddWatchedResourceIfNotAdded( + oplogConfig.MongoDBResourceRef.Name, + opsManager.Namespace, + watch.MongoDB, + kube.ObjectKeyFromApiObject(&opsManager), + ) + } + + // watch mongodb resources for block stores + blockstores := opsManager.Spec.Backup.BlockStoreConfigs + for _, blockStoreConfig := range blockstores { + r.AddWatchedResourceIfNotAdded( + blockStoreConfig.MongoDBResourceRef.Name, + opsManager.Namespace, + watch.MongoDB, + kube.ObjectKeyFromApiObject(&opsManager), + ) + } + + // watch mongodb resources for s3 stores + s3Stores := opsManager.Spec.Backup.S3Configs + for _, s3StoreConfig := range s3Stores { + // If S3StoreConfig doesn't have mongodb resource reference, skip it (appdb will be used) + if s3StoreConfig.MongoDBResourceRef != nil { + r.AddWatchedResourceIfNotAdded( + s3StoreConfig.MongoDBResourceRef.Name, + opsManager.Namespace, + watch.MongoDB, + kube.ObjectKeyFromApiObject(&opsManager), + ) + } + } + + r.watchMongoDBResourcesReferencedByKmip(opsManager, log) + r.watchCaReferencedByKmip(opsManager) +} + +// buildMongoConnectionUrl returns a connection URL to the appdb. +// +// Note, that it overrides the default authMechanism (which internally depends +// on the mongodb version). +func buildMongoConnectionUrl(opsManager omv1.MongoDBOpsManager, password string) string { + connectionString := opsManager.Spec.AppDB.BuildConnectionURL( + util.OpsManagerMongoDBUserName, + password, + connectionstring.SchemeMongoDB, + map[string]string{"authMechanism": "SCRAM-SHA-256"}) + + return connectionString +} + +func setConfigProperty(opsManager *omv1.MongoDBOpsManager, key, value string, log *zap.SugaredLogger) { + if opsManager.AddConfigIfDoesntExist(key, value) { + if key == util.MmsMongoUri { + log.Debugw("Configured property", key, util.RedactMongoURI(value)) + } else { + log.Debugw("Configured property", key, value) + } + } +} + +// ensureGenKey +func (r OpsManagerReconciler) ensureGenKey(om omv1.MongoDBOpsManager, log *zap.SugaredLogger) error { + objectKey := kube.ObjectKey(om.Namespace, om.Name+"-gen-key") + var opsManagerSecretPath string + if r.VaultClient != nil { + opsManagerSecretPath = r.VaultClient.OpsManagerSecretPath() + } + _, err := r.ReadSecret(objectKey, opsManagerSecretPath) + + if secrets.SecretNotExist(err) { + // todo if the key is not found but the AppDB is initialized - OM will fail to start as preflight + // check will complain that keys are different - we need to validate against this here + + // the length must be equal to 'EncryptionUtils.DES3_KEY_LENGTH' (24) from mms + token := make([]byte, 24) + rand.Read(token) + keyMap := map[string][]byte{"gen.key": token} + + log.Infof("Creating secret %s", objectKey) + + genKeySecret := secret.Builder(). + SetName(objectKey.Name). + SetNamespace(objectKey.Namespace). + SetLabels(map[string]string{}). + SetByteData(keyMap). + Build() + + return r.PutBinarySecret(genKeySecret, opsManagerSecretPath) + } + return err +} + +// getAppDBPassword will return the password that was specified by the user, or the auto generated password stored in +// the secret (generate it and store in secret otherwise) +func (r OpsManagerReconciler) getAppDBPassword(opsManager omv1.MongoDBOpsManager, log *zap.SugaredLogger) (string, error) { + passwordRef := opsManager.Spec.AppDB.PasswordSecretKeyRef + if passwordRef != nil && passwordRef.Name != "" { // there is a secret specified for the Ops Manager user + + password, err := secret.ReadKey(r.client, passwordRef.Key, kube.ObjectKey(opsManager.Namespace, passwordRef.Name)) + if err != nil { + if secrets.SecretNotExist(err) { + log.Debugf("Generated AppDB password and storing in secret/%s", opsManager.Spec.AppDB.GetOpsManagerUserPasswordSecretName()) + return r.generatePasswordAndCreateSecret(opsManager, log) + } + return "", err + } + log.Debugf("Reading password from secret/%s", passwordRef.Name) + + // watch for any changes on the user provided password + r.AddWatchedResourceIfNotAdded( + passwordRef.Name, + opsManager.Namespace, + watch.Secret, + kube.ObjectKeyFromApiObject(&opsManager), + ) + + // delete the auto generated password, we don't need it anymore. We can just generate a new one if + // the user password is deleted + log.Debugf("Deleting Operator managed password secret/%s from namespace", opsManager.Spec.AppDB.GetSecretName(), opsManager.Namespace) + if err := r.client.DeleteSecret(kube.ObjectKey(opsManager.Namespace, opsManager.Spec.AppDB.GetSecretName())); err != nil && !secrets.SecretNotExist(err) { + return "", err + } + + return password, nil + } + + // otherwise we'll ensure the auto generated password exists + secretObjectKey := kube.ObjectKey(opsManager.Namespace, opsManager.Spec.AppDB.GetSecretName()) + appDbPasswordSecretStringData, err := secret.ReadStringData(r.client, secretObjectKey) + + if secrets.SecretNotExist(err) { + // create the password + password, err := generate.RandomFixedLengthStringOfSize(12) + if err != nil { + return "", err + } + + passwordData := map[string]string{ + util.OpsManagerPasswordKey: password, + } + + log.Infof("Creating mongodb-ops-manager password in secret/%s in namespace %s", secretObjectKey.Name, secretObjectKey.Namespace) + + appDbPasswordSecret := secret.Builder(). + SetName(secretObjectKey.Name). + SetNamespace(secretObjectKey.Namespace). + SetStringMapToData(passwordData). + SetOwnerReferences(kube.BaseOwnerReference(&opsManager)). + Build() + + if err := r.client.CreateSecret(appDbPasswordSecret); err != nil { + return "", err + } + + log.Debugf("Using auto generated AppDB password stored in secret/%s", opsManager.Spec.AppDB.GetSecretName()) + return password, nil + } else if err != nil { + // any other error + return "", err + } + + log.Debugf("Using auto generated AppDB password stored in secret/%s", opsManager.Spec.AppDB.GetSecretName()) + return appDbPasswordSecretStringData[util.OpsManagerPasswordKey], nil +} + +func (r OpsManagerReconciler) getOpsManagerAPIKeySecretName(opsManager omv1.MongoDBOpsManager) (string, workflow.Status) { + var operatorVaultSecretPath string + if r.VaultClient != nil { + operatorVaultSecretPath = r.VaultClient.OperatorSecretPath() + } + APISecretName, err := opsManager.APIKeySecretName(r.SecretClient, operatorVaultSecretPath) + if err != nil { + return "", workflow.Failed(xerrors.Errorf("failed to get ops-manager API key secret name: %w", err)).WithRetry(10) + } + return APISecretName, workflow.OK() +} + +func detailedAPIErrorMsg(adminKeySecretName types.NamespacedName) string { + return fmt.Sprintf("This is a fatal error, as the"+ + " Operator requires public API key for the admin user to exist. Please create the GLOBAL_ADMIN user in "+ + "Ops Manager manually and create a secret '%s' with fields '%s' and '%s'", adminKeySecretName, util.OmPublicApiKey, + util.OmPrivateKey) +} + +// prepareOpsManager ensures the admin user is created and the admin public key exists. It returns the instance of +// api.OpsManagerAdmin to perform future Ops Manager configuration +// Note the exception handling logic - if the controller fails to save the public API key secret - it cannot fix this +// manually (the first OM user can be created only once) - so the resource goes to Failed state and shows the message +// asking the user to fix this manually. +// Theoretically the Operator could remove the appdb StatefulSet (as the OM must be empty without any user data) and +// allow the db to get recreated but seems this is a quite radical operation. +func (r OpsManagerReconciler) prepareOpsManager(opsManager omv1.MongoDBOpsManager, log *zap.SugaredLogger) (workflow.Status, api.OpsManagerAdmin) { + // We won't support cross-namespace secrets until CLOUDP-46636 is resolved + adminObjectKey := kube.ObjectKey(opsManager.Namespace, opsManager.Spec.AdminSecret) + + var operatorVaultPath string + if r.VaultClient != nil { + operatorVaultPath = r.VaultClient.OperatorSecretPath() + } + + // 1. Read the admin secret + userData, err := r.ReadSecret(adminObjectKey, operatorVaultPath) + + if secrets.SecretNotExist(err) { + // This requires user actions - let's wait a bit longer than 10 seconds + return workflow.Failed(xerrors.Errorf("the secret %s doesn't exist - you need to create it to finish Ops Manager initialization", adminObjectKey)).WithRetry(60), nil + } else if err != nil { + return workflow.Failed(err), nil + } + + user, err := newUserFromSecret(userData) + if err != nil { + return workflow.Failed(xerrors.Errorf("failed to read user data from the secret %s: %w", adminObjectKey, err)), nil + } + APISecretName, status := r.getOpsManagerAPIKeySecretName(opsManager) + if !status.IsOK() { + return status, nil + } + + adminKeySecretName := kube.ObjectKey(operatorNamespace(), APISecretName) + + // 2. Create a user in Ops Manager if necessary. Note, that we don't send the request if the API key secret exists. + // This is because of the weird Ops Manager /unauth endpoint logic: it allows to create any number of users though only + // the first one will have GLOBAL_ADMIN permission. So we should avoid the situation when the admin changes the + // user secret and reconciles OM resource and the new user (non admin one) is created overriding the previous API secret + _, err = r.ReadSecret(adminKeySecretName, operatorVaultPath) + + if secrets.SecretNotExist(err) { + apiKey, err := r.omInitializer.TryCreateUser(opsManager.CentralURL(), opsManager.Spec.Version, user) + if err != nil { + // Will wait more than usual (10 seconds) as most of all the problem needs to get fixed by the user + // by modifying the credentials secret + return workflow.Failed(xerrors.Errorf("failed to create an admin user in Ops Manager: %w", err)).WithRetry(30), nil + } + + // Recreate an admin key secret in the Operator namespace if the user was created + if apiKey.PublicKey != "" { + log.Infof("Created an admin user %s with GLOBAL_ADMIN role", user.Username) + + // The structure matches the structure of a credentials secret used by normal mongodb resources + secretData := map[string]string{util.OmPublicApiKey: apiKey.PublicKey, util.OmPrivateKey: apiKey.PrivateKey} + + if err = r.client.DeleteSecret(adminKeySecretName); err != nil && !secrets.SecretNotExist(err) { + // TODO our desired behavior is not to fail but just append the warning to the status (CLOUDP-51340) + return workflow.Failed(xerrors.Errorf("failed to replace a secret for admin public api key. %s. The error : %w", + detailedAPIErrorMsg(adminKeySecretName), err)).WithRetry(300), nil + } + + adminSecretBuilder := secret.Builder(). + SetNamespace(adminKeySecretName.Namespace). + SetName(adminKeySecretName.Name). + SetStringMapToData(secretData). + SetLabels(map[string]string{}) + + if opsManager.Namespace == operatorNamespace() { + // The Secret where the admin-key is saved is created in the Namespace where the + // Operator resides. + // The Secret's OwnerReference is only added if both the Secret and Ops Manager + // reside in the same Namespace because cross-namespace OwnerReferences are not + // allowed. + // More information in: CLOUDP-90848 + adminSecretBuilder.SetOwnerReferences(kube.BaseOwnerReference(&opsManager)) + } + adminSecret := adminSecretBuilder.Build() + + if err := r.PutSecret(adminSecret, operatorVaultPath); err != nil { + // TODO see above + return workflow.Failed(xerrors.Errorf("failed to create a secret for admin public api key. %s. The error : %w", + detailedAPIErrorMsg(adminKeySecretName), err)).WithRetry(30), nil + } + log.Infof("Created a secret for admin public api key %s", adminKeySecretName) + + // Each "read-after-write" operation needs some timeout after write unfortunately :( + // https://github.com/kubernetes-sigs/controller-runtime/issues/343#issuecomment-468402446 + time.Sleep(time.Duration(env.ReadIntOrDefault(util.K8sCacheRefreshEnv, util.DefaultK8sCacheRefreshTimeSeconds)) * time.Second) + } else { + log.Debug("Ops Manager did not return a valid User object.") + } + } + + // 3. Final validation of current state - this could be the retry after failing to create the secret during + // previous reconciliation (and the apiKey is empty as "the first user already exists") - the only fix is + // to create the secret manually + _, err = r.ReadSecret(adminKeySecretName, operatorVaultPath) + if err != nil { + return workflow.Failed(xerrors.Errorf("admin API key secret for Ops Manager doesn't exit - was it removed accidentally? %s. The error : %w", + detailedAPIErrorMsg(adminKeySecretName), err)).WithRetry(30), nil + } + // Ops Manager api key Secret has the same structure as the MongoDB credentials secret + APIKeySecretName, err := opsManager.APIKeySecretName(r.SecretClient, operatorVaultPath) + if err != nil { + return workflow.Failed(err), nil + } + + cred, err := project.ReadCredentials(r.SecretClient, kube.ObjectKey(operatorNamespace(), APIKeySecretName), log) + if err != nil { + return workflow.Failed(err), nil + } + + admin := r.omAdminProvider(opsManager.CentralURL(), cred.PublicAPIKey, cred.PrivateAPIKey) + return workflow.OK(), admin +} + +// prepareBackupInOpsManager makes the changes to backup admin configuration based on the Ops Manager spec +func (r *OpsManagerReconciler) prepareBackupInOpsManager(opsManager omv1.MongoDBOpsManager, omAdmin api.OpsManagerAdmin, + log *zap.SugaredLogger) workflow.Status { + if !opsManager.Spec.Backup.Enabled { + return workflow.OK() + } + + // 1. Enabling Daemon Config if necessary + backupHostNames := opsManager.BackupDaemonFQDNs() + for _, hostName := range backupHostNames { + dc, err := omAdmin.ReadDaemonConfig(hostName, util.PvcMountPathHeadDb) + if apierror.NewNonNil(err).ErrorCode == apierror.BackupDaemonConfigNotFound { + log.Infow("Backup Daemons is not configured, enabling it", "hostname", hostName, "headDB", util.PvcMountPathHeadDb) + + err = omAdmin.CreateDaemonConfig(hostName, util.PvcMountPathHeadDb, opsManager.Spec.Backup.AssignmentLabels) + if apierror.NewNonNil(err).ErrorCode == apierror.BackupDaemonConfigNotFound { + // Unfortunately by this time backup daemon may not have been started yet and we don't have proper + // mechanism to ensure this using readiness probe so we just retry + return workflow.Pending("BackupDaemon hasn't started yet") + } else if err != nil { + return workflow.Failed(err) + } + } else if err != nil { + return workflow.Failed(err) + } else { + // The Assignment Labels are the only thing that can change at the moment. + // If we add new features for controlling the Backup Daemons, we may want + // to compare the whole backup.DaemonConfig objects. + if !reflect.DeepEqual(opsManager.Spec.Backup.AssignmentLabels, dc.Labels) { + dc.Labels = opsManager.Spec.Backup.AssignmentLabels + err = omAdmin.UpdateDaemonConfig(dc) + if err != nil { + return workflow.Failed(err) + } + } + } + } + + // 2. Oplog store configs + status := r.ensureOplogStoresInOpsManager(opsManager, omAdmin, log) + + // 3. S3 Oplog Configs + status = status.Merge(r.ensureS3OplogStoresInOpsManager(opsManager, omAdmin, log)) + + // 4. S3 Configs + status = status.Merge(r.ensureS3ConfigurationInOpsManager(opsManager, omAdmin, log)) + + // 5. Block store configs + status = status.Merge(r.ensureBlockStoresInOpsManager(opsManager, omAdmin, log)) + + // 6. FileSystem store configs + status = status.Merge(r.ensureFileSystemStoreConfigurationInOpsManager(opsManager, omAdmin, log)) + if len(opsManager.Spec.Backup.S3Configs) == 0 && len(opsManager.Spec.Backup.BlockStoreConfigs) == 0 && len(opsManager.Spec.Backup.FileSystemStoreConfigs) == 0 { + return status.Merge(workflow.Invalid("Either S3 or Blockstore or FileSystem Snapshot configuration is required for backup").WithTargetPhase(mdbstatus.PhasePending)) + } + + return status +} + +// ensureOplogStoresInOpsManager aligns the oplog stores in Ops Manager with the Operator state. So it adds the new configs +// and removes the non-existing ones. Note that there's no update operation as so far the Operator manages only one field +// 'path'. This will allow users to make any additional changes to the file system stores using Ops Manager UI and the +// Operator won't override them +func (r *OpsManagerReconciler) ensureOplogStoresInOpsManager(opsManager omv1.MongoDBOpsManager, omAdmin api.OplogStoreAdmin, log *zap.SugaredLogger) workflow.Status { + if !opsManager.Spec.Backup.Enabled { + return workflow.OK() + } + + opsManagerOplogConfigs, err := omAdmin.ReadOplogStoreConfigs() + if err != nil { + return workflow.Failed(err) + } + + // Creating new configs + operatorOplogConfigs := opsManager.Spec.Backup.OplogStoreConfigs + configsToCreate := identifiable.SetDifferenceGeneric(operatorOplogConfigs, opsManagerOplogConfigs) + for _, v := range configsToCreate { + omConfig, status := r.buildOMDatastoreConfig(opsManager, v.(omv1.DataStoreConfig)) + if !status.IsOK() { + return status + } + log.Debugw("Creating Oplog Store in Ops Manager", "config", omConfig) + if err = omAdmin.CreateOplogStoreConfig(omConfig); err != nil { + return workflow.Failed(err) + } + } + + // Updating existing configs. It intersects the OM API configs with Operator spec configs and returns pairs + //["omConfig", "operatorConfig"]. + configsToUpdate := identifiable.SetIntersectionGeneric(opsManagerOplogConfigs, operatorOplogConfigs) + for _, v := range configsToUpdate { + omConfig := v[0].(backup.DataStoreConfig) + operatorConfig := v[1].(omv1.DataStoreConfig) + operatorView, status := r.buildOMDatastoreConfig(opsManager, operatorConfig) + if !status.IsOK() { + return status + } + + // Now we need to merge the Operator version into the OM one overriding only the fields that the Operator + // "owns" + configToUpdate := operatorView.MergeIntoOpsManagerConfig(omConfig) + log.Debugw("Updating Oplog Store in Ops Manager", "config", configToUpdate) + if err = omAdmin.UpdateOplogStoreConfig(configToUpdate); err != nil { + return workflow.Failed(err) + } + } + + // Removing non-existing configs + configsToRemove := identifiable.SetDifferenceGeneric(opsManagerOplogConfigs, opsManager.Spec.Backup.OplogStoreConfigs) + for _, v := range configsToRemove { + log.Debugf("Removing Oplog Store %s from Ops Manager", v.Identifier()) + if err = omAdmin.DeleteOplogStoreConfig(v.Identifier().(string)); err != nil { + return workflow.Failed(err) + } + } + + operatorS3OplogConfigs := opsManager.Spec.Backup.S3OplogStoreConfigs + if len(operatorOplogConfigs) == 0 && len(operatorS3OplogConfigs) == 0 { + return workflow.Invalid("Oplog Store configuration is required for backup").WithTargetPhase(mdbstatus.PhasePending) + } + return workflow.OK() +} + +func (r OpsManagerReconciler) ensureS3OplogStoresInOpsManager(opsManager omv1.MongoDBOpsManager, s3OplogAdmin api.S3OplogStoreAdmin, log *zap.SugaredLogger) workflow.Status { + if !opsManager.Spec.Backup.Enabled { + return workflow.OK() + } + + opsManagerS3OpLogConfigs, err := s3OplogAdmin.ReadS3OplogStoreConfigs() + if err != nil { + return workflow.Failed(err) + } + + // Creating new configs + s3OperatorOplogConfigs := opsManager.Spec.Backup.S3OplogStoreConfigs + configsToCreate := identifiable.SetDifferenceGeneric(s3OperatorOplogConfigs, opsManagerS3OpLogConfigs) + for _, v := range configsToCreate { + omConfig, status := r.buildOMS3Config(opsManager, v.(omv1.S3Config), log) + if !status.IsOK() { + return status + } + log.Infow("Creating S3 Oplog Store in Ops Manager", "config", omConfig) + if err = s3OplogAdmin.CreateS3OplogStoreConfig(omConfig); err != nil { + return workflow.Failed(err) + } + } + + // Updating existing configs. It intersects the OM API configs with Operator spec configs and returns pairs + //["omConfig", "operatorConfig"]. + configsToUpdate := identifiable.SetIntersectionGeneric(opsManagerS3OpLogConfigs, s3OperatorOplogConfigs) + for _, v := range configsToUpdate { + omConfig := v[0].(backup.S3Config) + operatorConfig := v[1].(omv1.S3Config) + operatorView, status := r.buildOMS3Config(opsManager, operatorConfig, log) + if !status.IsOK() { + return status + } + + // Now we need to merge the Operator version into the OM one overriding only the fields that the Operator + // "owns" + configToUpdate := operatorView.MergeIntoOpsManagerConfig(omConfig) + log.Infow("Updating S3 Oplog Store in Ops Manager", "config", configToUpdate) + if err = s3OplogAdmin.UpdateS3OplogConfig(configToUpdate); err != nil { + return workflow.Failed(err) + } + } + + // Removing non-existing configs + configsToRemove := identifiable.SetDifferenceGeneric(opsManagerS3OpLogConfigs, opsManager.Spec.Backup.S3OplogStoreConfigs) + for _, v := range configsToRemove { + log.Infof("Removing Oplog Store %s from Ops Manager", v.Identifier()) + if err = s3OplogAdmin.DeleteS3OplogStoreConfig(v.Identifier().(string)); err != nil { + return workflow.Failed(err) + } + } + + operatorOplogConfigs := opsManager.Spec.Backup.OplogStoreConfigs + if len(operatorOplogConfigs) == 0 && len(s3OperatorOplogConfigs) == 0 { + return workflow.Invalid("Oplog Store configuration is required for backup").WithTargetPhase(mdbstatus.PhasePending) + } + return workflow.OK() +} + +// ensureBlockStoresInOpsManager aligns the blockStore configs in Ops Manager with the Operator state. So it adds the new configs +// and removes the non-existing ones. Note that there's no update operation as so far the Operator manages only one field +// 'path'. This will allow users to make any additional changes to the file system stores using Ops Manager UI and the +// Operator won't override them +func (r *OpsManagerReconciler) ensureBlockStoresInOpsManager(opsManager omv1.MongoDBOpsManager, omAdmin api.BlockStoreAdmin, log *zap.SugaredLogger) workflow.Status { + if !opsManager.Spec.Backup.Enabled { + return workflow.OK() + } + + opsManagerBlockStoreConfigs, err := omAdmin.ReadBlockStoreConfigs() + if err != nil { + return workflow.Failed(err) + } + + // Creating new configs + operatorBlockStoreConfigs := opsManager.Spec.Backup.BlockStoreConfigs + configsToCreate := identifiable.SetDifferenceGeneric(operatorBlockStoreConfigs, opsManagerBlockStoreConfigs) + for _, v := range configsToCreate { + omConfig, status := r.buildOMDatastoreConfig(opsManager, v.(omv1.DataStoreConfig)) + if !status.IsOK() { + return status + } + log.Debugw("Creating Block Store in Ops Manager", "config", omConfig) + if err = omAdmin.CreateBlockStoreConfig(omConfig); err != nil { + return workflow.Failed(err) + } + } + + // Updating existing configs. It intersects the OM API configs with Operator spec configs and returns pairs + //["omConfig", "operatorConfig"]. + configsToUpdate := identifiable.SetIntersectionGeneric(opsManagerBlockStoreConfigs, operatorBlockStoreConfigs) + for _, v := range configsToUpdate { + omConfig := v[0].(backup.DataStoreConfig) + operatorConfig := v[1].(omv1.DataStoreConfig) + operatorView, status := r.buildOMDatastoreConfig(opsManager, operatorConfig) + if !status.IsOK() { + return status + } + + // Now we need to merge the Operator version into the OM one overriding only the fields that the Operator + // "owns" + configToUpdate := operatorView.MergeIntoOpsManagerConfig(omConfig) + log.Debugw("Updating Block Store in Ops Manager", "config", configToUpdate) + if err = omAdmin.UpdateBlockStoreConfig(configToUpdate); err != nil { + return workflow.Failed(err) + } + } + + // Removing non-existing configs + configsToRemove := identifiable.SetDifferenceGeneric(opsManagerBlockStoreConfigs, opsManager.Spec.Backup.BlockStoreConfigs) + for _, v := range configsToRemove { + log.Debugf("Removing Block Store %s from Ops Manager", v.Identifier()) + if err = omAdmin.DeleteBlockStoreConfig(v.Identifier().(string)); err != nil { + return workflow.Failed(err) + } + } + return workflow.OK() +} + +func (r *OpsManagerReconciler) ensureS3ConfigurationInOpsManager(opsManager omv1.MongoDBOpsManager, omAdmin api.S3StoreBlockStoreAdmin, + log *zap.SugaredLogger) workflow.Status { + if !opsManager.Spec.Backup.Enabled { + return workflow.OK() + } + + opsManagerS3Configs, err := omAdmin.ReadS3Configs() + if err != nil { + return workflow.Failed(err) + } + + operatorS3Configs := opsManager.Spec.Backup.S3Configs + configsToCreate := identifiable.SetDifferenceGeneric(operatorS3Configs, opsManagerS3Configs) + for _, config := range configsToCreate { + omConfig, status := r.buildOMS3Config(opsManager, config.(omv1.S3Config), log) + if !status.IsOK() { + return status + } + + log.Infow("Creating S3Config in Ops Manager", "config", omConfig) + if err := omAdmin.CreateS3Config(omConfig); err != nil { + return workflow.Failed(err) + } + } + + // Updating existing configs. It intersects the OM API configs with Operator spec configs and returns pairs + //["omConfig", "operatorConfig"]. + configsToUpdate := identifiable.SetIntersectionGeneric(opsManagerS3Configs, operatorS3Configs) + for _, v := range configsToUpdate { + omConfig := v[0].(backup.S3Config) + operatorConfig := v[1].(omv1.S3Config) + operatorView, status := r.buildOMS3Config(opsManager, operatorConfig, log) + if !status.IsOK() { + return status + } + + // Now we need to merge the Operator version into the OM one overriding only the fields that the Operator + // "owns" + configToUpdate := operatorView.MergeIntoOpsManagerConfig(omConfig) + log.Infow("Updating S3Config in Ops Manager", "config", configToUpdate) + if err = omAdmin.UpdateS3Config(configToUpdate); err != nil { + return workflow.Failed(err) + } + } + + configsToRemove := identifiable.SetDifferenceGeneric(opsManagerS3Configs, operatorS3Configs) + for _, config := range configsToRemove { + log.Infof("Removing S3Config %s from Ops Manager", config.Identifier()) + if err := omAdmin.DeleteS3Config(config.Identifier().(string)); err != nil { + return workflow.Failed(err) + } + } + + return workflow.OK() +} + +// readS3Credentials reads the access and secret keys from the awsCredentials secret specified +// in the resource +func (r *OpsManagerReconciler) readS3Credentials(s3SecretName, namespace string) (*backup.S3Credentials, error) { + var operatorSecretPath string + if r.VaultClient != nil { + operatorSecretPath = r.VaultClient.OperatorSecretPath() + } + + s3SecretData, err := r.ReadSecret(kube.ObjectKey(namespace, s3SecretName), operatorSecretPath) + if err != nil { + return nil, err + } + + s3Creds := &backup.S3Credentials{} + if accessKey, ok := s3SecretData[util.S3AccessKey]; !ok { + return nil, xerrors.Errorf("key %s was not present in the secret %s", util.S3AccessKey, s3SecretName) + } else { + s3Creds.AccessKey = accessKey + } + + if secretKey, ok := s3SecretData[util.S3SecretKey]; !ok { + return nil, xerrors.Errorf("key %s was not present in the secret %s", util.S3SecretKey, s3SecretName) + } else { + s3Creds.SecretKey = secretKey + } + + return s3Creds, nil +} + +// ensureFileSystemStoreConfigurationInOpsManage makes sure that the FileSystem snapshot stores specified in the +// MongoDB CR are configured correctly in OpsManager. +func (r *OpsManagerReconciler) ensureFileSystemStoreConfigurationInOpsManager(opsManager omv1.MongoDBOpsManager, omAdmin api.OpsManagerAdmin, log *zap.SugaredLogger) workflow.Status { + opsManagefsStoreConfigs, err := omAdmin.ReadFileSystemStoreConfigs() + if err != nil { + return workflow.Failed(err) + } + + fsStoreNames := make(map[string]struct{}) + for _, e := range opsManager.Spec.Backup.FileSystemStoreConfigs { + fsStoreNames[e.Name] = struct{}{} + } + // count the number of FS snapshots configured in OM and match them with the one in CR. + countFS := 0 + + for _, e := range opsManagefsStoreConfigs { + if _, ok := fsStoreNames[e.Id]; ok { + countFS++ + } + } + + if countFS != len(opsManager.Spec.Backup.FileSystemStoreConfigs) { + return workflow.Failed(xerrors.Errorf("Not all fileSystem snapshots have been configured in OM.")) + } + return workflow.OK() +} + +// shouldUseAppDb accepts an S3Config and returns true if the AppDB should be used +// for this S3 configuration. Otherwise, a MongoDB resource is configured for use. +func shouldUseAppDb(config omv1.S3Config) bool { + return config.MongoDBResourceRef == nil +} + +// buildAppDbOMS3Config creates a backup.S3Config which is configured to use The AppDb. +func (r *OpsManagerReconciler) buildAppDbOMS3Config(om omv1.MongoDBOpsManager, config omv1.S3Config, + log *zap.SugaredLogger) (backup.S3Config, workflow.Status) { + + password, err := r.getAppDBPassword(om, log) + if err != nil { + return backup.S3Config{}, workflow.Failed(err) + } + var s3Creds *backup.S3Credentials + + if !config.IRSAEnabled { + s3Creds, err = r.readS3Credentials(config.S3SecretRef.Name, om.Namespace) + if err != nil { + return backup.S3Config{}, workflow.Failed(err) + } + } + + uri := buildMongoConnectionUrl(om, password) + + bucket := backup.S3Bucket{ + Endpoint: config.S3BucketEndpoint, + Name: config.S3BucketName, + } + + customCAOpts, err := r.readCustomCAFilePathsAndContents(om) + if err != nil { + return backup.S3Config{}, workflow.Failed(err) + } + + return backup.NewS3Config(om, config, uri, customCAOpts, bucket, s3Creds), workflow.OK() +} + +// buildMongoDbOMS3Config creates a backup.S3Config which is configured to use a referenced +// MongoDB resource. +func (r *OpsManagerReconciler) buildMongoDbOMS3Config(opsManager omv1.MongoDBOpsManager, config omv1.S3Config) (backup.S3Config, workflow.Status) { + mongodb, status := r.getMongoDbForS3Config(opsManager, config) + if !status.IsOK() { + return backup.S3Config{}, status + } + + if status := validateS3Config(mongodb.GetAuthenticationModes(), mongodb.GetResourceName(), config); !status.IsOK() { + return backup.S3Config{}, status + } + + userName, password, status := r.getS3MongoDbUserNameAndPassword(mongodb.GetAuthenticationModes(), opsManager.Namespace, config) + if !status.IsOK() { + return backup.S3Config{}, status + } + + var s3Creds *backup.S3Credentials + var err error + + if !config.IRSAEnabled { + s3Creds, err = r.readS3Credentials(config.S3SecretRef.Name, opsManager.Namespace) + if err != nil { + return backup.S3Config{}, workflow.Failed(err) + } + } + + uri := mongodb.BuildConnectionString(userName, password, connectionstring.SchemeMongoDB, map[string]string{}) + + bucket := backup.S3Bucket{ + Endpoint: config.S3BucketEndpoint, + Name: config.S3BucketName, + } + + customCAOpts, err := r.readCustomCAFilePathsAndContents(opsManager) + if err != nil { + return backup.S3Config{}, workflow.Failed(err) + } + + return backup.NewS3Config(opsManager, config, uri, customCAOpts, bucket, s3Creds), workflow.OK() +} + +// readCustomCAFilePathsAndContents returns the filepath and contents of the custom CA which is used to configure +// the S3Store. +func (r *OpsManagerReconciler) readCustomCAFilePathsAndContents(opsManager omv1.MongoDBOpsManager) (backup.S3CustomCertificate, error) { + if opsManager.Spec.GetAppDbCA() != "" { + filePath := util.AppDBMmsCaFileDirInContainer + "ca-pem" + cmContents, err := configmap.ReadKey(r.client, "ca-pem", kube.ObjectKey(opsManager.Namespace, opsManager.Spec.GetAppDbCA())) + if err != nil { + return backup.S3CustomCertificate{}, err + + } + return backup.S3CustomCertificate{ + Filename: filePath, + CertString: cmContents, + }, nil + } + return backup.S3CustomCertificate{}, nil +} + +// buildOMS3Config builds the OM API S3 config from the Operator OM CR configuration. This involves some logic to +// get the mongo URI which points to either the external resource or to the AppDB +func (r *OpsManagerReconciler) buildOMS3Config(opsManager omv1.MongoDBOpsManager, config omv1.S3Config, + log *zap.SugaredLogger) (backup.S3Config, workflow.Status) { + if shouldUseAppDb(config) { + return r.buildAppDbOMS3Config(opsManager, config, log) + } + return r.buildMongoDbOMS3Config(opsManager, config) +} + +// getMongoDbForS3Config returns the referenced MongoDB resource which should be used when configuring the backup config. +func (r *OpsManagerReconciler) getMongoDbForS3Config(opsManager omv1.MongoDBOpsManager, config omv1.S3Config) (S3ConfigGetter, workflow.Status) { + mongodb, mongodbMulti := &mdbv1.MongoDB{}, &mdbmulti.MongoDBMultiCluster{} + mongodbObjectKey := config.MongodbResourceObjectKey(opsManager) + + err := r.client.Get(context.TODO(), mongodbObjectKey, mongodb) + if err != nil { + if secrets.SecretNotExist(err) { + + // try to fetch mongodbMulti if it exists + err = r.client.Get(context.TODO(), mongodbObjectKey, mongodbMulti) + if err != nil { + if secrets.SecretNotExist(err) { + // Returning pending as the user may create the mongodb resource soon + return nil, workflow.Pending("The MongoDB object %s doesn't exist", mongodbObjectKey) + } + return nil, workflow.Failed(err) + } + return mongodbMulti, workflow.OK() + } + + return nil, workflow.Failed(err) + } + + return mongodb, workflow.OK() +} + +// getS3MongoDbUserNameAndPassword returns userName and password if MongoDB resource has scram-sha enabled. +// Note, that we don't worry if the 'mongodbUserRef' is specified but SCRAM-SHA is not enabled - we just ignore the +// user. +func (r *OpsManagerReconciler) getS3MongoDbUserNameAndPassword(modes []string, namespace string, config omv1.S3Config) (string, string, workflow.Status) { + if !stringutil.Contains(modes, util.SCRAM) { + return "", "", workflow.OK() + } + mongodbUser := &user.MongoDBUser{} + mongodbUserObjectKey := config.MongodbUserObjectKey(namespace) + err := r.client.Get(context.TODO(), mongodbUserObjectKey, mongodbUser) + if secrets.SecretNotExist(err) { + return "", "", workflow.Pending("The MongoDBUser object %s doesn't exist", mongodbUserObjectKey) + } + if err != nil { + return "", "", workflow.Failed(xerrors.Errorf("Failed to fetch the user %s: %w", mongodbUserObjectKey, err)) + } + userName := mongodbUser.Spec.Username + password, err := mongodbUser.GetPassword(r.SecretClient) + if err != nil { + return "", "", workflow.Failed(xerrors.Errorf("Failed to read password for the user %s: %w", mongodbUserObjectKey, err)) + } + return userName, password, workflow.OK() +} + +// buildOMDatastoreConfig builds the OM API datastore config based on the Kubernetes OM resource one. +// To do this it may need to read the Mongodb User and its password to build mongodb url correctly +func (r *OpsManagerReconciler) buildOMDatastoreConfig(opsManager omv1.MongoDBOpsManager, operatorConfig omv1.DataStoreConfig) (backup.DataStoreConfig, workflow.Status) { + mongodb := &mdbv1.MongoDB{} + mongodbObjectKey := operatorConfig.MongodbResourceObjectKey(opsManager.Namespace) + + err := r.client.Get(context.TODO(), mongodbObjectKey, mongodb) + if err != nil { + if secrets.SecretNotExist(err) { + // Returning pending as the user may create the mongodb resource soon + return backup.DataStoreConfig{}, workflow.Pending("The MongoDB object %s doesn't exist", mongodbObjectKey) + } + return backup.DataStoreConfig{}, workflow.Failed(err) + } + + status := validateDataStoreConfig(mongodb.Spec.Security.Authentication.GetModes(), mongodb.Name, operatorConfig) + if !status.IsOK() { + return backup.DataStoreConfig{}, status + } + + // If MongoDB resource has scram-sha enabled then we need to read the username and the password. + // Note, that we don't worry if the 'mongodbUserRef' is specified but SCRAM-SHA is not enabled - we just ignore the + // user + var userName, password string + if stringutil.Contains(mongodb.Spec.Security.Authentication.GetModes(), util.SCRAM) { + mongodbUser := &user.MongoDBUser{} + mongodbUserObjectKey := operatorConfig.MongodbUserObjectKey(opsManager.Namespace) + err := r.client.Get(context.TODO(), mongodbUserObjectKey, mongodbUser) + if secrets.SecretNotExist(err) { + return backup.DataStoreConfig{}, workflow.Pending("The MongoDBUser object %s doesn't exist", operatorConfig.MongodbResourceObjectKey(opsManager.Namespace)) + } + if err != nil { + return backup.DataStoreConfig{}, workflow.Failed(xerrors.Errorf("Failed to fetch the user %s: %w", operatorConfig.MongodbResourceObjectKey(opsManager.Namespace), err)) + } + userName = mongodbUser.Spec.Username + password, err = mongodbUser.GetPassword(r.SecretClient) + if err != nil { + return backup.DataStoreConfig{}, workflow.Failed(xerrors.Errorf("Failed to read password for the user %s: %w", mongodbUserObjectKey, err)) + } + } + + tls := mongodb.Spec.Security.TLSConfig.Enabled + mongoUri := mongodb.BuildConnectionString(userName, password, connectionstring.SchemeMongoDB, map[string]string{}) + return backup.NewDataStoreConfig(operatorConfig.Name, mongoUri, tls, operatorConfig.AssignmentLabels), workflow.OK() +} + +func validateS3Config(modes []string, mdbName string, s3Config omv1.S3Config) workflow.Status { + return validateConfig(modes, mdbName, s3Config.MongoDBUserRef, "S3 metadata database") +} + +func validateDataStoreConfig(modes []string, mdbName string, dataStoreConfig omv1.DataStoreConfig) workflow.Status { + return validateConfig(modes, mdbName, dataStoreConfig.MongoDBUserRef, "Oplog/Blockstore databases") +} + +func validateConfig(modes []string, mdbName string, userRef *omv1.MongoDBUserRef, description string) workflow.Status { + // validate + if !stringutil.Contains(modes, util.SCRAM) && + len(modes) > 0 { + return workflow.Failed(xerrors.Errorf("The only authentication mode supported for the %s is SCRAM-SHA", description)) + } + if stringutil.Contains(modes, util.SCRAM) && + (userRef == nil || userRef.Name == "") { + return workflow.Failed(xerrors.Errorf("MongoDB resource %s is configured to use SCRAM-SHA authentication mode, the user must be"+ + " specified using 'mongodbUserRef'", mdbName)) + } + + return workflow.OK() +} + +func newUserFromSecret(data map[string]string) (api.User, error) { + // validate + for _, v := range []string{"Username", "Password", "FirstName", "LastName"} { + if _, ok := data[v]; !ok { + return api.User{}, xerrors.Errorf("%s property is missing in the admin secret", v) + } + } + user := api.User{Username: data["Username"], + Password: data["Password"], + FirstName: data["FirstName"], + LastName: data["LastName"], + } + return user, nil +} + +// delete cleans up Ops Manager related resources on CR removal. +func (r *OpsManagerReconciler) delete(obj interface{}, log *zap.SugaredLogger) { + opsManager := obj.(*omv1.MongoDBOpsManager) + + r.RemoveAllDependentWatchedResources(opsManager.Namespace, kube.ObjectKeyFromApiObject(opsManager)) + r.RemoveDependentWatchedResources(opsManager.AppDBStatefulSetObjectKey()) + + log.Info("Cleaned up Ops Manager related resources.") +} + +// getAnnotationsForOpsManagerResource returns all of the annotations that should be applied to the resource +// at the end of the reconciliation. +func getAnnotationsForOpsManagerResource(opsManager *omv1.MongoDBOpsManager) (map[string]string, error) { + finalAnnotations := make(map[string]string) + specBytes, err := json.Marshal(opsManager.Spec) + if err != nil { + return nil, err + } + finalAnnotations[util.LastAchievedSpec] = string(specBytes) + return finalAnnotations, nil +} diff --git a/controllers/operator/mongodbopsmanager_controller_test.go b/controllers/operator/mongodbopsmanager_controller_test.go new file mode 100644 index 000000000..280e17c04 --- /dev/null +++ b/controllers/operator/mongodbopsmanager_controller_test.go @@ -0,0 +1,1082 @@ +package operator + +import ( + "context" + "encoding/json" + "fmt" + "net" + "os" + "testing" + "time" + + v1 "github.com/10gen/ops-manager-kubernetes/api/v1" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/statefulset" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/authentication/scram" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/authentication/scramcredentials" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/automationconfig" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/apierror" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/secret" + + omv1 "github.com/10gen/ops-manager-kubernetes/api/v1/om" + userv1 "github.com/10gen/ops-manager-kubernetes/api/v1/user" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/10gen/ops-manager-kubernetes/api/v1/status" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/agents" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/mock" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/watch" + "k8s.io/apimachinery/pkg/types" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/workflow" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/controllers/om/api" + operatorConstruct "github.com/10gen/ops-manager-kubernetes/controllers/operator/construct" + "github.com/stretchr/testify/assert" + "go.uber.org/zap" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func init() { + os.Setenv(util.AppDBReadinessWaitEnv, "0") +} + +func TestOpsManagerReconciler_watchedResources(t *testing.T) { + testOm := DefaultOpsManagerBuilder().Build() + otherTestOm := DefaultOpsManagerBuilder().Build() + otherTestOm.Name = "otherOM" + + otherTestOm.Spec.Backup.Enabled = true + testOm.Spec.Backup.Enabled = true + otherTestOm.Spec.Backup.OplogStoreConfigs = []omv1.DataStoreConfig{{MongoDBResourceRef: userv1.MongoDBResourceRef{Name: "oplog1"}}} + testOm.Spec.Backup.OplogStoreConfigs = []omv1.DataStoreConfig{{MongoDBResourceRef: userv1.MongoDBResourceRef{Name: "oplog1"}}} + + reconciler, _, _ := defaultTestOmReconciler(t, testOm) + reconciler.watchMongoDBResourcesReferencedByBackup(testOm, zap.S()) + reconciler.watchMongoDBResourcesReferencedByBackup(otherTestOm, zap.S()) + + key := watch.Object{ + ResourceType: watch.MongoDB, + Resource: types.NamespacedName{ + Name: "oplog1", + Namespace: testOm.Namespace, + }, + } + + // om watches oplog MDB resource + assert.Contains(t, reconciler.WatchedResources, key) + assert.Contains(t, reconciler.WatchedResources[key], mock.ObjectKeyFromApiObject(&testOm)) + assert.Contains(t, reconciler.WatchedResources[key], mock.ObjectKeyFromApiObject(&otherTestOm)) +} + +// TestOMTLSResourcesAreWatchedAndUnwatched verifies that TLS config map and secret are added to the internal +// map that allows to watch them for changes +func TestOMTLSResourcesAreWatchedAndUnwatched(t *testing.T) { + testOm := DefaultOpsManagerBuilder().SetBackup(omv1.MongoDBOpsManagerBackup{ + Enabled: true, + }).SetAppDBTLSConfig(mdbv1.TLSConfig{ + Enabled: true, + CA: "custom-ca-appdb", + }).SetTLSConfig(omv1.MongoDBOpsManagerTLS{ + SecretRef: omv1.TLSSecretRef{ + Name: "om-tls-secret", + }, + CA: "custom-ca", + }). + AddOplogStoreConfig("oplog-store-2", "my-user", types.NamespacedName{Name: "config-0-mdb", Namespace: mock.TestNamespace}). + AddBlockStoreConfig("block-store-config-0", "my-user", types.NamespacedName{Name: "config-0-mdb", Namespace: mock.TestNamespace}). + Build() + + testOm.Spec.Backup.Encryption = &omv1.Encryption{ + Kmip: &omv1.KmipConfig{ + Server: v1.KmipServerConfig{ + CA: "custom-kmip-ca", + URL: "kmip:8080", + }, + }, + } + + reconciler, client, _ := defaultTestOmReconciler(t, testOm) + addOMTLSResources(client, "om-tls-secret") + addAppDBTLSResources(client, testOm.Spec.AppDB, testOm.Spec.AppDB.GetTlsCertificatesSecretName()) + addKMIPTestResources(client, testOm, "test-mdb", "test-prefix") + configureBackupResources(client, testOm) + + checkOMReconciliationSuccessful(t, reconciler, &testOm) + + ns := testOm.Namespace + KmipCaKey := getWatch(ns, "custom-kmip-ca", watch.ConfigMap) + omCAKey := getWatch(ns, "custom-ca", watch.ConfigMap) + appDBCAKey := getWatch(ns, "custom-ca-appdb", watch.ConfigMap) + KmipMongoDBKey := getWatch(ns, "test-prefix-test-mdb-kmip-client", watch.Secret) + KmipMongoDBPasswordKey := getWatch(ns, "test-prefix-test-mdb-kmip-client-password", watch.Secret) + omTLSSecretKey := getWatch(ns, "om-tls-secret", watch.Secret) + appdbTLSecretCert := getWatch(ns, "test-om-db-cert", watch.Secret) + + expectedWatchedResources := []watch.Object{ + getWatch("testNS", "test-mdb", watch.MongoDB), + getWatch(ns, "config-0-mdb", watch.MongoDB), + KmipCaKey, + omCAKey, + appDBCAKey, + KmipMongoDBKey, + KmipMongoDBPasswordKey, + omTLSSecretKey, + appdbTLSecretCert, + } + + var actual []watch.Object + for obj := range reconciler.WatchedResources { + actual = append(actual, obj) + } + + assert.ElementsMatch(t, expectedWatchedResources, actual) + testOm.Spec.Security.TLS.SecretRef.Name = "" + testOm.Spec.Backup.Enabled = false + + err := client.Update(context.TODO(), &testOm) + assert.NoError(t, err) + + res, err := reconciler.Reconcile(context.TODO(), requestFromObject(&testOm)) + assert.Equal(t, reconcile.Result{}, res) + assert.NoError(t, err) + + assert.NotContains(t, reconciler.WatchedResources, omTLSSecretKey) + assert.NotContains(t, reconciler.WatchedResources, omCAKey) + assert.NotContains(t, reconciler.WatchedResources, KmipMongoDBKey) + assert.NotContains(t, reconciler.WatchedResources, KmipMongoDBPasswordKey) + assert.NotContains(t, reconciler.WatchedResources, KmipCaKey) + + testOm.Spec.AppDB.Security.TLSConfig.Enabled = false + testOm.Spec.Backup.Enabled = true + testOm.Spec.Backup.Encryption.Kmip = nil + err = client.Update(context.TODO(), &testOm) + assert.NoError(t, err) + + res, err = reconciler.Reconcile(context.TODO(), requestFromObject(&testOm)) + assert.Equal(t, reconcile.Result{}, res) + assert.NoError(t, err) + + assert.NotContains(t, reconciler.WatchedResources, appDBCAKey) + assert.NotContains(t, reconciler.WatchedResources, appdbTLSecretCert) + assert.NotContains(t, reconciler.WatchedResources, KmipMongoDBKey) + assert.NotContains(t, reconciler.WatchedResources, KmipMongoDBPasswordKey) + assert.NotContains(t, reconciler.WatchedResources, KmipCaKey) +} + +func TestOpsManagerPrefixForTLSSecret(t *testing.T) { + testOm := DefaultOpsManagerBuilder().SetBackup(omv1.MongoDBOpsManagerBackup{ + Enabled: false, + }).SetTLSConfig(omv1.MongoDBOpsManagerTLS{ + CA: "custom-ca", + }).Build() + + testOm.Spec.Security.CertificatesSecretsPrefix = "prefix" + assert.Equal(t, fmt.Sprintf("prefix-%s-cert", testOm.Name), testOm.TLSCertificateSecretName()) + + testOm.Spec.Security.TLS.SecretRef.Name = "om-tls-secret" + assert.Equal(t, "om-tls-secret", testOm.TLSCertificateSecretName()) +} + +func TestOpsManagerReconciler_removeWatchedResources(t *testing.T) { + resourceName := "oplog1" + testOm := DefaultOpsManagerBuilder().Build() + testOm.Spec.Backup.Enabled = true + testOm.Spec.Backup.OplogStoreConfigs = []omv1.DataStoreConfig{{MongoDBResourceRef: userv1.MongoDBResourceRef{Name: resourceName}}} + + reconciler, _, _ := defaultTestOmReconciler(t, testOm) + reconciler.watchMongoDBResourcesReferencedByBackup(testOm, zap.S()) + + key := watch.Object{ + ResourceType: watch.MongoDB, + Resource: types.NamespacedName{Name: resourceName, Namespace: testOm.Namespace}, + } + + // om watches oplog MDB resource + assert.Contains(t, reconciler.WatchedResources, key) + assert.Contains(t, reconciler.WatchedResources[key], mock.ObjectKeyFromApiObject(&testOm)) + + // watched resources list is cleared when CR is deleted + reconciler.delete(&testOm, zap.S()) + assert.Zero(t, len(reconciler.WatchedResources)) +} + +func TestOpsManagerReconciler_prepareOpsManager(t *testing.T) { + testOm := DefaultOpsManagerBuilder().Build() + reconciler, client, initializer := defaultTestOmReconciler(t, testOm) + + reconcileStatus, _ := reconciler.prepareOpsManager(testOm, zap.S()) + + assert.Equal(t, workflow.OK(), reconcileStatus) + assert.Equal(t, "jane.doe@g.com", api.CurrMockedAdmin.PublicKey) + + // the user "created" in Ops Manager + assert.Len(t, initializer.currentUsers, 1) + assert.Equal(t, "Jane", initializer.currentUsers[0].FirstName) + assert.Equal(t, "Doe", initializer.currentUsers[0].LastName) + assert.Equal(t, "pwd", initializer.currentUsers[0].Password) + assert.Equal(t, "jane.doe@g.com", initializer.currentUsers[0].Username) + + // One secret was created by the user, another one - by the Operator for the user public key + assert.Len(t, client.GetMapForObject(&corev1.Secret{}), 2) + expectedSecretData := map[string]string{"publicKey": "jane.doe@g.com", "privateKey": "jane.doe@g.com-key"} + + APIKeySecretName, err := testOm.APIKeySecretName(client, "") + assert.NoError(t, err) + + existingSecretData, _ := secret.ReadStringData(client, kube.ObjectKey(OperatorNamespace, APIKeySecretName)) + assert.Equal(t, expectedSecretData, existingSecretData) +} + +// TestOpsManagerReconciler_prepareOpsManagerTwoCalls checks that second call to 'prepareOpsManager' doesn't call +// OM api to create a user as the API secret already exists +func TestOpsManagerReconciler_prepareOpsManagerTwoCalls(t *testing.T) { + testOm := DefaultOpsManagerBuilder().Build() + reconciler, client, initializer := defaultTestOmReconciler(t, testOm) + + reconciler.prepareOpsManager(testOm, zap.S()) + + APIKeySecretName, err := testOm.APIKeySecretName(client, "") + assert.NoError(t, err) + + // let's "update" the user admin secret - this must not affect anything + client.GetMapForObject(&corev1.Secret{})[kube.ObjectKey(OperatorNamespace, APIKeySecretName)].(*corev1.Secret).Data["Username"] = []byte("this-is-not-expected@g.com") + + // second call is ok - we just don't create the admin user in OM and don't add new secrets + reconcileStatus, _ := reconciler.prepareOpsManager(testOm, zap.S()) + assert.Equal(t, workflow.OK(), reconcileStatus) + assert.Equal(t, "jane.doe@g.com-key", api.CurrMockedAdmin.PrivateKey) + + // the call to the api didn't happen + assert.Equal(t, 1, initializer.numberOfCalls) + assert.Len(t, initializer.currentUsers, 1) + assert.Equal(t, "jane.doe@g.com", initializer.currentUsers[0].Username) + + assert.Len(t, client.GetMapForObject(&corev1.Secret{}), 2) + + data, _ := secret.ReadStringData(client, kube.ObjectKey(OperatorNamespace, APIKeySecretName)) + assert.Equal(t, "jane.doe@g.com", data["publicKey"]) +} + +// TestOpsManagerReconciler_prepareOpsManagerDuplicatedUser checks that if the public API key secret is removed by the +// user - the Operator will try to create a user again and this will result in UserAlreadyExists error +func TestOpsManagerReconciler_prepareOpsManagerDuplicatedUser(t *testing.T) { + testOm := DefaultOpsManagerBuilder().Build() + reconciler, client, initializer := defaultTestOmReconciler(t, testOm) + + reconciler.prepareOpsManager(testOm, zap.S()) + + APIKeySecretName, err := testOm.APIKeySecretName(client, "") + assert.NoError(t, err) + + // for some reasons the admin removed the public Api key secret so the call will be done to OM to create a user - + // it will fail as the user already exists + _ = client.Delete(context.TODO(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: OperatorNamespace, Name: APIKeySecretName}, + }) + + reconcileStatus, admin := reconciler.prepareOpsManager(testOm, zap.S()) + assert.Equal(t, status.PhaseFailed, reconcileStatus.Phase()) + + option, exists := status.GetOption(reconcileStatus.StatusOptions(), status.MessageOption{}) + assert.True(t, exists) + assert.Contains(t, option.(status.MessageOption).Message, "USER_ALREADY_EXISTS") + reconcileStatus.StatusOptions() + assert.Nil(t, admin) + + // the call to the api happened, but the user wasn't added + assert.Equal(t, 2, initializer.numberOfCalls) + assert.Len(t, initializer.currentUsers, 1) + assert.Equal(t, "jane.doe@g.com", initializer.currentUsers[0].Username) + + // api secret wasn't created + assert.Len(t, client.GetMapForObject(&corev1.Secret{}), 1) + + assert.NotContains(t, client.GetMapForObject(&corev1.Secret{}), kube.ObjectKey(OperatorNamespace, APIKeySecretName)) +} + +func TestOpsManagerGeneratesAppDBPassword_IfNotProvided(t *testing.T) { + testOm := DefaultOpsManagerBuilder().Build() + reconciler, _, _ := defaultTestOmReconciler(t, testOm) + + password, err := reconciler.getAppDBPassword(testOm, zap.S()) + assert.NoError(t, err) + assert.Len(t, password, 12, "auto generated password should have a size of 12") +} + +func TestOpsManagerUsersPassword_SpecifiedInSpec(t *testing.T) { + testOm := DefaultOpsManagerBuilder().SetAppDBPassword("my-secret", "password").Build() + reconciler, client, _ := defaultTestOmReconciler(t, testOm) + + client.GetMapForObject(&corev1.Secret{})[kube.ObjectKey(testOm.Namespace, testOm.Spec.AppDB.PasswordSecretKeyRef.Name)] = &corev1.Secret{ + Data: map[string][]byte{ + testOm.Spec.AppDB.PasswordSecretKeyRef.Key: []byte("my-password"), // create the secret with the password + }, + } + + password, err := reconciler.getAppDBPassword(testOm, zap.S()) + + assert.NoError(t, err) + assert.Equal(t, password, "my-password", "the password specified by the SecretRef should have been returned when specified") +} + +func TestBackupStatefulSetIsNotRemoved_WhenDisabled(t *testing.T) { + testOm := DefaultOpsManagerBuilder().SetBackup(omv1.MongoDBOpsManagerBackup{ + Enabled: true, + }).Build() + reconciler, client, _ := defaultTestOmReconciler(t, testOm) + + checkOMReconciliationSuccessful(t, reconciler, &testOm) + + backupSts := appsv1.StatefulSet{} + err := client.Get(context.TODO(), kube.ObjectKey(testOm.Namespace, testOm.BackupStatefulSetName()), &backupSts) + assert.NoError(t, err, "Backup StatefulSet should have been created when backup is enabled") + + testOm.Spec.Backup.Enabled = false + err = client.Update(context.TODO(), &testOm) + assert.NoError(t, err) + + res, err := reconciler.Reconcile(context.TODO(), requestFromObject(&testOm)) + assert.Equal(t, reconcile.Result{}, res) + assert.NoError(t, err) + + backupSts = appsv1.StatefulSet{} + err = client.Get(context.TODO(), kube.ObjectKey(testOm.Namespace, testOm.BackupStatefulSetName()), &backupSts) + assert.NoError(t, err, "Backup StatefulSet should not be removed when backup is disabled") +} + +func TestOpsManagerPodTemplateSpec_IsAnnotatedWithHash(t *testing.T) { + testOm := DefaultOpsManagerBuilder().SetBackup(omv1.MongoDBOpsManagerBackup{ + Enabled: false, + }).Build() + reconciler, client, _ := defaultTestOmReconciler(t, testOm) + + s := secret.Builder(). + SetName(testOm.Spec.AppDB.GetOpsManagerUserPasswordSecretName()). + SetNamespace(testOm.Namespace). + SetOwnerReferences(kube.BaseOwnerReference(&testOm)). + SetByteData(map[string][]byte{ + "password": []byte("password"), + }).Build() + + err := reconciler.client.UpdateSecret(s) + assert.NoError(t, err) + + checkOMReconciliationSuccessful(t, reconciler, &testOm) + + connectionString, err := secret.ReadKey(reconciler.client, util.AppDbConnectionStringKey, kube.ObjectKey(testOm.Namespace, testOm.AppDBMongoConnectionStringSecretName())) + assert.NoError(t, err) + assert.NotEmpty(t, connectionString) + + sts := appsv1.StatefulSet{} + err = client.Get(context.TODO(), kube.ObjectKey(testOm.Namespace, testOm.Name), &sts) + assert.NoError(t, err) + + podTemplate := sts.Spec.Template + + assert.Contains(t, podTemplate.Annotations, "connectionStringHash") + assert.Equal(t, podTemplate.Annotations["connectionStringHash"], hashConnectionString(buildMongoConnectionUrl(testOm, "password"))) + testOm.Spec.AppDB.Members = 5 + assert.NotEqual(t, podTemplate.Annotations["connectionStringHash"], hashConnectionString(buildMongoConnectionUrl(testOm, "password")), + "Changing the number of members should result in a different Connection String and different hash") + testOm.Spec.AppDB.Members = 3 + testOm.Spec.AppDB.Version = "4.2.0" + assert.Equal(t, podTemplate.Annotations["connectionStringHash"], hashConnectionString(buildMongoConnectionUrl(testOm, "password")), + "Changing version should not change connection string and so the hash should stay the same") +} + +func TestOpsManagerConnectionString_IsPassedAsSecretRef(t *testing.T) { + testOm := DefaultOpsManagerBuilder().SetBackup(omv1.MongoDBOpsManagerBackup{ + Enabled: false, + }).Build() + reconciler, client, _ := defaultTestOmReconciler(t, testOm) + + checkOMReconciliationSuccessful(t, reconciler, &testOm) + + sts := appsv1.StatefulSet{} + err := client.Get(context.TODO(), kube.ObjectKey(testOm.Namespace, testOm.Name), &sts) + assert.NoError(t, err) + + var uriVol corev1.Volume + for _, v := range sts.Spec.Template.Spec.Volumes { + if v.Name == operatorConstruct.AppDBConnectionStringVolume { + uriVol = v + break + } + } + assert.NotEmpty(t, uriVol.Name, "MmsMongoUri volume should have been present!") + assert.NotNil(t, uriVol.VolumeSource) + assert.NotNil(t, uriVol.VolumeSource.Secret) + assert.Equal(t, uriVol.VolumeSource.Secret.SecretName, testOm.AppDBMongoConnectionStringSecretName()) +} + +func TestOpsManagerWithKMIP(t *testing.T) { + //given + kmipURL := "kmip.mongodb.com:5696" + kmipCAConfigMapName := "kmip-ca" + mdbName := "test-mdb" + + clientCertificatePrefix := "test-prefix" + expectedClientCertificateSecretName := clientCertificatePrefix + "-" + mdbName + "-kmip-client" + + testOm := DefaultOpsManagerBuilder(). + AddOplogStoreConfig("oplog-store-2", "my-user", types.NamespacedName{Name: "config-0-mdb", Namespace: mock.TestNamespace}). + AddBlockStoreConfig("block-store-config-0", "my-user", types.NamespacedName{Name: "config-0-mdb", Namespace: mock.TestNamespace}). + Build() + + testOm.Spec.Backup.Encryption = &omv1.Encryption{ + Kmip: &omv1.KmipConfig{ + Server: v1.KmipServerConfig{ + CA: kmipCAConfigMapName, + URL: kmipURL, + }, + }, + } + + reconciler, client, _ := defaultTestOmReconciler(t, testOm) + addKMIPTestResources(client, testOm, mdbName, clientCertificatePrefix) + configureBackupResources(client, testOm) + + //when + checkOMReconciliationSuccessful(t, reconciler, &testOm) + sts := appsv1.StatefulSet{} + err := client.Get(context.TODO(), kube.ObjectKey(testOm.Namespace, testOm.Name), &sts) + envs := sts.Spec.Template.Spec.Containers[0].Env + volumes := sts.Spec.Template.Spec.Volumes + volumeMounts := sts.Spec.Template.Spec.Containers[0].VolumeMounts + + //then + assert.NoError(t, err) + host, port, _ := net.SplitHostPort(kmipURL) + + expectedVars := []corev1.EnvVar{ + {Name: "OM_PROP_backup_kmip_server_host", Value: host}, + {Name: "OM_PROP_backup_kmip_server_port", Value: port}, + {Name: "OM_PROP_backup_kmip_server_ca_file", Value: util.KMIPCAFileInContainer}, + } + assert.Subset(t, envs, expectedVars) + + expectedCAMount := corev1.VolumeMount{ + Name: util.KMIPServerCAName, + MountPath: util.KMIPServerCAHome, + ReadOnly: true, + } + assert.Contains(t, volumeMounts, expectedCAMount) + expectedClientCertMount := corev1.VolumeMount{ + Name: util.KMIPClientSecretNamePrefix + expectedClientCertificateSecretName, + MountPath: util.KMIPClientSecretsHome + "/" + expectedClientCertificateSecretName, + ReadOnly: true, + } + assert.Contains(t, volumeMounts, expectedClientCertMount) + + expectedCAVolume := statefulset.CreateVolumeFromConfigMap(util.KMIPServerCAName, kmipCAConfigMapName) + assert.Contains(t, volumes, expectedCAVolume) + expectedClientCertVolume := statefulset.CreateVolumeFromSecret(util.KMIPClientSecretNamePrefix+expectedClientCertificateSecretName, expectedClientCertificateSecretName) + assert.Contains(t, volumes, expectedClientCertVolume) +} + +// TODO move this test to 'opsmanager_types_test.go' when the builder is moved to 'apis' package +func TestOpsManagerCentralUrl(t *testing.T) { + assert.Equal(t, "http://test-om-svc.my-namespace.svc.cluster.local:8080", + DefaultOpsManagerBuilder().Build().CentralURL()) + assert.Equal(t, "http://test-om-svc.my-namespace.svc.some.domain:8080", + DefaultOpsManagerBuilder().SetClusterDomain("some.domain").Build().CentralURL()) +} + +// TODO move this test to 'opsmanager_types_test.go' when the builder is moved to 'apis' package +func TestOpsManagerBackupDaemonHostName(t *testing.T) { + assert.Equal(t, []string{"test-om-backup-daemon-0.test-om-backup-daemon-svc.my-namespace.svc.cluster.local"}, + DefaultOpsManagerBuilder().Build().BackupDaemonFQDNs()) + // The host name doesn't depend on cluster domain + assert.Equal(t, []string{"test-om-backup-daemon-0.test-om-backup-daemon-svc.my-namespace.svc.some.domain"}, + DefaultOpsManagerBuilder().SetClusterDomain("some.domain").Build().BackupDaemonFQDNs()) + + assert.Equal(t, []string{"test-om-backup-daemon-0.test-om-backup-daemon-svc.my-namespace.svc.cluster.local", "test-om-backup-daemon-1.test-om-backup-daemon-svc.my-namespace.svc.cluster.local", "test-om-backup-daemon-2.test-om-backup-daemon-svc.my-namespace.svc.cluster.local"}, + DefaultOpsManagerBuilder().SetBackupMembers(3).Build().BackupDaemonFQDNs()) +} + +func TestOpsManagerBackupAssignmentLabels(t *testing.T) { + // given + assignmentLabels := []string{"test"} + + testOm := DefaultOpsManagerBuilder(). + AddOplogStoreConfig("oplog-store-2", "my-user", types.NamespacedName{Name: "config-0-mdb", Namespace: mock.TestNamespace}). + AddBlockStoreConfig("block-store-config-0", "my-user", types.NamespacedName{Name: "config-0-mdb", Namespace: mock.TestNamespace}). + AddS3Config("s3-config", "s3-secret"). + Build() + + testOm.Spec.Backup.AssignmentLabels = assignmentLabels + testOm.Spec.Backup.OplogStoreConfigs[0].AssignmentLabels = assignmentLabels + testOm.Spec.Backup.BlockStoreConfigs[0].AssignmentLabels = assignmentLabels + testOm.Spec.Backup.S3Configs[0].AssignmentLabels = assignmentLabels + + reconciler, client, _ := defaultTestOmReconciler(t, testOm) + configureBackupResources(client, testOm) + + mockedAdmin := api.NewMockedAdminProvider("testUrl", "publicApiKey", "privateApiKey") + defer mockedAdmin.(*api.MockedOmAdmin).Reset() + + // when + reconciler.prepareBackupInOpsManager(testOm, mockedAdmin, zap.S()) + blockStoreConfigs, _ := mockedAdmin.ReadBlockStoreConfigs() + oplogConfigs, _ := mockedAdmin.ReadOplogStoreConfigs() + s3Configs, _ := mockedAdmin.ReadS3Configs() + daemonConfigs, _ := mockedAdmin.(*api.MockedOmAdmin).ReadDaemonConfigs() + + // then + assert.Equal(t, assignmentLabels, blockStoreConfigs[0].Labels) + assert.Equal(t, assignmentLabels, oplogConfigs[0].Labels) + assert.Equal(t, assignmentLabels, s3Configs[0].Labels) + assert.Equal(t, assignmentLabels, daemonConfigs[0].Labels) +} + +func TestTriggerOmChangedEventIfNeeded(t *testing.T) { + t.Run("Om changed event got triggered, major version update", func(t *testing.T) { + nextScheduledTime := agents.NextScheduledUpgradeTime() + assert.NoError(t, triggerOmChangedEventIfNeeded(omv1.NewOpsManagerBuilder().SetVersion("5.2.13").SetOMStatusVersion("4.2.13").Build(), zap.S())) + assert.NotEqual(t, nextScheduledTime, agents.NextScheduledUpgradeTime()) + }) + t.Run("Om changed event got triggered, minor version update", func(t *testing.T) { + nextScheduledTime := agents.NextScheduledUpgradeTime() + assert.NoError(t, triggerOmChangedEventIfNeeded(omv1.NewOpsManagerBuilder().SetVersion("4.4.0").SetOMStatusVersion("4.2.13").Build(), zap.S())) + assert.NotEqual(t, nextScheduledTime, agents.NextScheduledUpgradeTime()) + }) + t.Run("Om changed event got triggered, minor version update, candidate version", func(t *testing.T) { + nextScheduledTime := agents.NextScheduledUpgradeTime() + assert.NoError(t, triggerOmChangedEventIfNeeded(omv1.NewOpsManagerBuilder().SetVersion("4.4.0-rc2").SetOMStatusVersion("4.2.13").Build(), zap.S())) + assert.NotEqual(t, nextScheduledTime, agents.NextScheduledUpgradeTime()) + }) + t.Run("Om changed event not triggered, patch version update", func(t *testing.T) { + nextScheduledTime := agents.NextScheduledUpgradeTime() + assert.NoError(t, triggerOmChangedEventIfNeeded(omv1.NewOpsManagerBuilder().SetVersion("4.4.10").SetOMStatusVersion("4.4.0").Build(), zap.S())) + assert.Equal(t, nextScheduledTime, agents.NextScheduledUpgradeTime()) + }) +} + +func TestBackupIsStillConfigured_WhenAppDBIsConfigured_WithTls(t *testing.T) { + testOm := DefaultOpsManagerBuilder().AddS3Config("s3-config", "s3-secret"). + AddOplogStoreConfig("oplog-store-0", "my-user", types.NamespacedName{Name: "config-0-mdb", Namespace: mock.TestNamespace}). + SetAppDBTLSConfig(mdbv1.TLSConfig{Enabled: true}). + Build() + + reconciler, mockedClient, _ := defaultTestOmReconciler(t, testOm) + + addAppDBTLSResources(mockedClient, testOm.Spec.AppDB, fmt.Sprintf("%s-cert", testOm.Spec.AppDB.Name())) + configureBackupResources(mockedClient, testOm) + + // initially requeued as monitoring needs to be configured + res, err := reconciler.Reconcile(context.TODO(), requestFromObject(&testOm)) + assert.NoError(t, err) + assert.Equal(t, true, res.Requeue) + + // monitoring is configured successfully + res, err = reconciler.Reconcile(context.TODO(), requestFromObject(&testOm)) + + assert.NoError(t, err) + assert.Equal(t, false, res.Requeue) + assert.Equal(t, time.Duration(0), res.RequeueAfter) + +} + +func TestBackupConfig_ChangingName_ResultsIn_DeleteAndAdd(t *testing.T) { + testOm := DefaultOpsManagerBuilder(). + AddOplogStoreConfig("oplog-store", "my-user", types.NamespacedName{Name: "config-0-mdb", Namespace: mock.TestNamespace}). + AddS3Config("s3-config-0", "s3-secret"). + AddS3Config("s3-config-1", "s3-secret"). + AddS3Config("s3-config-2", "s3-secret"). + Build() + + reconciler, mockedClient, _ := defaultTestOmReconciler(t, testOm) + + configureBackupResources(mockedClient, testOm) + + // initially requeued as monitoring needs to be configured + res, err := reconciler.Reconcile(context.TODO(), requestFromObject(&testOm)) + assert.NoError(t, err) + assert.Equal(t, true, res.Requeue) + + // monitoring is configured successfully + res, err = reconciler.Reconcile(context.TODO(), requestFromObject(&testOm)) + assert.NoError(t, err) + + t.Run("Configs are created successfully", func(t *testing.T) { + s3Configs, err := api.CurrMockedAdmin.ReadS3Configs() + assert.NoError(t, err) + assert.Len(t, s3Configs, 3) + }) + + testOm.Spec.Backup.S3Configs[0].Name = "new-name" + err = mockedClient.Update(context.TODO(), &testOm) + assert.NoError(t, err) + + res, err = reconciler.Reconcile(context.TODO(), requestFromObject(&testOm)) + assert.NoError(t, err) + + t.Run("Name change resulted in a different config being created", func(t *testing.T) { + s3Configs, err := api.CurrMockedAdmin.ReadS3Configs() + assert.NoError(t, err) + assert.Len(t, s3Configs, 3) + + assert.Equal(t, "new-name", s3Configs[0].Id) + assert.Equal(t, "s3-config-1", s3Configs[1].Id) + assert.Equal(t, "s3-config-2", s3Configs[2].Id) + }) + +} + +func TestBackupConfigs_AreRemoved_WhenRemovedFromCR(t *testing.T) { + testOm := DefaultOpsManagerBuilder(). + AddS3Config("s3-config-0", "s3-secret"). + AddS3Config("s3-config-1", "s3-secret"). + AddS3Config("s3-config-2", "s3-secret"). + AddOplogStoreConfig("oplog-store-0", "my-user", types.NamespacedName{Name: "config-0-mdb", Namespace: mock.TestNamespace}). + AddOplogStoreConfig("oplog-store-1", "my-user", types.NamespacedName{Name: "config-0-mdb", Namespace: mock.TestNamespace}). + AddOplogStoreConfig("oplog-store-2", "my-user", types.NamespacedName{Name: "config-0-mdb", Namespace: mock.TestNamespace}). + AddBlockStoreConfig("block-store-config-0", "my-user", types.NamespacedName{Name: "config-0-mdb", Namespace: mock.TestNamespace}). + AddBlockStoreConfig("block-store-config-1", "my-user", types.NamespacedName{Name: "config-0-mdb", Namespace: mock.TestNamespace}). + AddBlockStoreConfig("block-store-config-2", "my-user", types.NamespacedName{Name: "config-0-mdb", Namespace: mock.TestNamespace}). + Build() + + reconciler, mockedClient, _ := defaultTestOmReconciler(t, testOm) + + configureBackupResources(mockedClient, testOm) + + // initially requeued as monitoring needs to be configured + res, err := reconciler.Reconcile(context.TODO(), requestFromObject(&testOm)) + assert.NoError(t, err) + assert.Equal(t, true, res.Requeue) + + // monitoring is configured successfully + res, err = reconciler.Reconcile(context.TODO(), requestFromObject(&testOm)) + + assert.NoError(t, err) + assert.Equal(t, false, res.Requeue) + assert.Equal(t, time.Duration(0), res.RequeueAfter) + + t.Run("Configs are created successfully", func(t *testing.T) { + configs, err := api.CurrMockedAdmin.ReadOplogStoreConfigs() + assert.NoError(t, err) + assert.Len(t, configs, 3) + + s3Configs, err := api.CurrMockedAdmin.ReadS3Configs() + assert.NoError(t, err) + assert.Len(t, s3Configs, 3) + + blockstores, err := api.CurrMockedAdmin.ReadBlockStoreConfigs() + assert.NoError(t, err) + assert.Len(t, blockstores, 3) + }) + + // remove the first entry + testOm.Spec.Backup.OplogStoreConfigs = testOm.Spec.Backup.OplogStoreConfigs[1:] + + // remove middle element + testOm.Spec.Backup.S3Configs = []omv1.S3Config{testOm.Spec.Backup.S3Configs[0], testOm.Spec.Backup.S3Configs[2]} + + // remove first and last + testOm.Spec.Backup.BlockStoreConfigs = []omv1.DataStoreConfig{testOm.Spec.Backup.BlockStoreConfigs[1]} + + err = mockedClient.Update(context.TODO(), &testOm) + assert.NoError(t, err) + + res, err = reconciler.Reconcile(context.TODO(), requestFromObject(&testOm)) + assert.NoError(t, err) + + t.Run("Configs are removed successfully", func(t *testing.T) { + configs, err := api.CurrMockedAdmin.ReadOplogStoreConfigs() + assert.NoError(t, err) + assert.Len(t, configs, 2) + + assert.Equal(t, "oplog-store-1", configs[0].Id) + assert.Equal(t, "oplog-store-2", configs[1].Id) + + s3Configs, err := api.CurrMockedAdmin.ReadS3Configs() + assert.NoError(t, err) + assert.Len(t, s3Configs, 2) + + assert.Equal(t, "s3-config-0", s3Configs[0].Id) + assert.Equal(t, "s3-config-2", s3Configs[1].Id) + + blockstores, err := api.CurrMockedAdmin.ReadBlockStoreConfigs() + assert.NoError(t, err) + assert.Len(t, blockstores, 1) + assert.Equal(t, "block-store-config-1", blockstores[0].Id) + + }) + +} + +func TestEnsureResourcesForArchitectureChange(t *testing.T) { + om := DefaultOpsManagerBuilder().Build() + + t.Run("When no automation config is present, there is no error", func(t *testing.T) { + client := mock.NewClient() + err := ensureResourcesForArchitectureChange(client, om) + assert.NoError(t, err) + }) + + t.Run("If User is not present, there is an error", func(t *testing.T) { + client := mock.NewClient() + ac, err := automationconfig.NewBuilder().SetAuth(automationconfig.Auth{ + Users: []automationconfig.MongoDBUser{ + { + Username: "not-ops-manager-user", + }}, + }).Build() + + assert.NoError(t, err) + + acBytes, err := json.Marshal(ac) + assert.NoError(t, err) + + // create the automation config secret + err = client.CreateSecret(secret.Builder().SetNamespace(om.Namespace).SetName(om.Spec.AppDB.AutomationConfigSecretName()).SetField(automationconfig.ConfigKey, string(acBytes)).Build()) + assert.NoError(t, err) + + err = ensureResourcesForArchitectureChange(client, om) + assert.Error(t, err) + }) + + t.Run("If an automation config is present, all secrets are created with the correct values", func(t *testing.T) { + client := mock.NewClient() + ac, err := automationconfig.NewBuilder().SetAuth(automationconfig.Auth{ + AutoPwd: "VrBQgsUZJJs", + Key: "Z8PSBtvvjnvds4zcI6iZ", + Users: []automationconfig.MongoDBUser{ + { + Username: util.OpsManagerMongoDBUserName, + ScramSha256Creds: &scramcredentials.ScramCreds{ + Salt: "sha256-salt-value", + ServerKey: "sha256-serverkey-value", + StoredKey: "sha256-storedkey-value", + }, + ScramSha1Creds: &scramcredentials.ScramCreds{ + Salt: "sha1-salt-value", + ServerKey: "sha1-serverkey-value", + StoredKey: "sha1-storedkey-value", + }, + }}, + }).Build() + + assert.NoError(t, err) + + acBytes, err := json.Marshal(ac) + assert.NoError(t, err) + + // create the automation config secret + err = client.CreateSecret(secret.Builder().SetNamespace(om.Namespace).SetName(om.Spec.AppDB.AutomationConfigSecretName()).SetField(automationconfig.ConfigKey, string(acBytes)).Build()) + assert.NoError(t, err) + + // create the old ops manager user password + err = client.CreateSecret(secret.Builder().SetNamespace(om.Namespace).SetName(om.Spec.AppDB.Name()+"-password").SetField("my-password", "jrJP7eUeyn").Build()) + assert.NoError(t, err) + + err = ensureResourcesForArchitectureChange(client, om) + assert.NoError(t, err) + + t.Run("Scram credentials have been created", func(t *testing.T) { + scramCreds, err := client.GetSecret(kube.ObjectKey(om.Namespace, om.Spec.AppDB.OpsManagerUserScramCredentialsName())) + assert.NoError(t, err) + + assert.Equal(t, ac.Auth.Users[0].ScramSha256Creds.Salt, string(scramCreds.Data["sha256-salt"])) + assert.Equal(t, ac.Auth.Users[0].ScramSha256Creds.StoredKey, string(scramCreds.Data["sha-256-stored-key"])) + assert.Equal(t, ac.Auth.Users[0].ScramSha256Creds.ServerKey, string(scramCreds.Data["sha-256-server-key"])) + + assert.Equal(t, ac.Auth.Users[0].ScramSha1Creds.Salt, string(scramCreds.Data["sha1-salt"])) + assert.Equal(t, ac.Auth.Users[0].ScramSha1Creds.StoredKey, string(scramCreds.Data["sha-1-stored-key"])) + assert.Equal(t, ac.Auth.Users[0].ScramSha1Creds.ServerKey, string(scramCreds.Data["sha-1-server-key"])) + }) + + t.Run("Ops Manager user password has been copied", func(t *testing.T) { + newOpsManagerUserPassword, err := client.GetSecret(kube.ObjectKey(om.Namespace, om.Spec.AppDB.GetOpsManagerUserPasswordSecretName())) + assert.NoError(t, err) + assert.Equal(t, string(newOpsManagerUserPassword.Data["my-password"]), "jrJP7eUeyn") + }) + + t.Run("Agent password has been created", func(t *testing.T) { + agentPasswordSecret, err := client.GetSecret(om.Spec.AppDB.GetAgentPasswordSecretNamespacedName()) + assert.NoError(t, err) + assert.Equal(t, ac.Auth.AutoPwd, string(agentPasswordSecret.Data[scram.AgentPasswordKey])) + }) + + t.Run("Keyfile has been created", func(t *testing.T) { + keyFileSecret, err := client.GetSecret(om.Spec.AppDB.GetAgentKeyfileSecretNamespacedName()) + assert.NoError(t, err) + assert.Equal(t, ac.Auth.Key, string(keyFileSecret.Data[scram.AgentKeyfileKey])) + }) + }) + +} + +func TestDependentResources_AreRemoved_WhenBackupIsDisabled(t *testing.T) { + testOm := DefaultOpsManagerBuilder(). + AddS3Config("s3-config-0", "s3-secret"). + AddS3Config("s3-config-1", "s3-secret"). + AddS3Config("s3-config-2", "s3-secret"). + AddOplogStoreConfig("oplog-store-0", "my-user", types.NamespacedName{Name: "config-0-mdb", Namespace: mock.TestNamespace}). + AddOplogStoreConfig("oplog-store-1", "my-user", types.NamespacedName{Name: "config-1-mdb", Namespace: mock.TestNamespace}). + AddOplogStoreConfig("oplog-store-2", "my-user", types.NamespacedName{Name: "config-2-mdb", Namespace: mock.TestNamespace}). + AddBlockStoreConfig("block-store-config-0", "my-user", types.NamespacedName{Name: "block-store-config-0-mdb", Namespace: mock.TestNamespace}). + AddBlockStoreConfig("block-store-config-1", "my-user", types.NamespacedName{Name: "block-store-config-1-mdb", Namespace: mock.TestNamespace}). + AddBlockStoreConfig("block-store-config-2", "my-user", types.NamespacedName{Name: "block-store-config-2-mdb", Namespace: mock.TestNamespace}). + Build() + + reconciler, mockedClient, _ := defaultTestOmReconciler(t, testOm) + + configureBackupResources(mockedClient, testOm) + + // initially requeued as monitoring needs to be configured + res, err := reconciler.Reconcile(context.TODO(), requestFromObject(&testOm)) + assert.NoError(t, err) + assert.Equal(t, true, res.Requeue) + + // monitoring is configured successfully + res, err = reconciler.Reconcile(context.TODO(), requestFromObject(&testOm)) + assert.NoError(t, err) + + t.Run("All MongoDB resource should be watched.", func(t *testing.T) { + assert.Len(t, reconciler.GetWatchedResourcesOfType(watch.MongoDB, testOm.Namespace), 6, "All non S3 configs should have a corresponding MongoDB resource and should be watched.") + }) + + t.Run("Removing backup configs causes the resource no longer be watched", func(t *testing.T) { + // remove last + testOm.Spec.Backup.BlockStoreConfigs = testOm.Spec.Backup.BlockStoreConfigs[0:2] + // remove first + testOm.Spec.Backup.OplogStoreConfigs = testOm.Spec.Backup.OplogStoreConfigs[1:3] + err = mockedClient.Update(context.TODO(), &testOm) + assert.NoError(t, err) + + res, err = reconciler.Reconcile(context.TODO(), requestFromObject(&testOm)) + assert.NoError(t, err) + + watchedResources := reconciler.GetWatchedResourcesOfType(watch.MongoDB, testOm.Namespace) + assert.Len(t, watchedResources, 4, "The two configs that were removed should no longer be watched.") + + assert.True(t, containsName("block-store-config-0-mdb", watchedResources)) + assert.True(t, containsName("block-store-config-1-mdb", watchedResources)) + assert.True(t, containsName("config-1-mdb", watchedResources)) + assert.True(t, containsName("config-2-mdb", watchedResources)) + + }) + + t.Run("Disabling backup should cause all resources to no longer be watched.", func(t *testing.T) { + testOm.Spec.Backup.Enabled = false + err = mockedClient.Update(context.TODO(), &testOm) + assert.NoError(t, err) + + res, err = reconciler.Reconcile(context.TODO(), requestFromObject(&testOm)) + assert.NoError(t, err) + assert.Len(t, reconciler.GetWatchedResourcesOfType(watch.MongoDB, testOm.Namespace), 0, "Backup has been disabled, none of the resources should be watched anymore.") + }) + +} + +func containsName(name string, nsNames []types.NamespacedName) bool { + for _, nsName := range nsNames { + if nsName.Name == name { + return true + } + } + return false +} + +// configureBackupResources ensures all of the dependent resources for the Backup configuration +// are created in the mocked client. This includes MongoDB resources for OplogStores, S3 credentials secrets +// MongodbUsers and their credentials secrets. +func configureBackupResources(m *mock.MockedClient, testOm omv1.MongoDBOpsManager) { + // configure S3 Secret + for _, s3Config := range testOm.Spec.Backup.S3Configs { + s3Creds := secret.Builder(). + SetName(s3Config.S3SecretRef.Name). + SetNamespace(testOm.Namespace). + SetField(util.S3AccessKey, "s3AccessKey"). + SetField(util.S3SecretKey, "s3SecretKey"). + Build() + _ = m.CreateSecret(s3Creds) + } + + // create MDB resource for oplog configs + for _, oplogConfig := range append(testOm.Spec.Backup.OplogStoreConfigs, testOm.Spec.Backup.BlockStoreConfigs...) { + oplogStoreResource := mdbv1.NewReplicaSetBuilder(). + SetName(oplogConfig.MongoDBResourceRef.Name). + SetNamespace(testOm.Namespace). + SetVersion("3.6.9"). + SetMembers(3). + EnableAuth([]string{util.SCRAM}). + Build() + + _ = m.Update(context.TODO(), oplogStoreResource) + + // create user for mdb resource + oplogStoreUser := DefaultMongoDBUserBuilder(). + SetResourceName(oplogConfig.MongoDBUserRef.Name). + SetNamespace(testOm.Namespace). + Build() + + _ = m.Update(context.TODO(), oplogStoreUser) + + // create secret for user + userPasswordSecret := secret.Builder(). + SetNamespace(testOm.Namespace). + SetName(oplogStoreUser.Spec.PasswordSecretKeyRef.Name). + SetField(oplogStoreUser.Spec.PasswordSecretKeyRef.Key, "KeJfV1ucQ_vZl"). + Build() + + _ = m.CreateSecret(userPasswordSecret) + } +} + +func defaultTestOmReconciler(t *testing.T, opsManager omv1.MongoDBOpsManager) (*OpsManagerReconciler, *mock.MockedClient, + *MockedInitializer) { + manager := mock.NewManager(&opsManager) + // create an admin user secret + data := map[string]string{"Username": "jane.doe@g.com", "Password": "pwd", "FirstName": "Jane", "LastName": "Doe"} + + s := secret.Builder(). + SetName(opsManager.Spec.AdminSecret). + SetNamespace(opsManager.Namespace). + SetStringMapToData(data). + SetLabels(map[string]string{}). + SetOwnerReferences(kube.BaseOwnerReference(&opsManager)). + Build() + + initializer := &MockedInitializer{expectedOmURL: opsManager.CentralURL(), t: t} + reconciler := newOpsManagerReconciler(manager, om.NewEmptyMockedOmConnection, initializer, func(baseUrl, user, publicApiKey string) api.OpsManagerAdmin { + if api.CurrMockedAdmin == nil { + api.CurrMockedAdmin = api.NewMockedAdminProvider(baseUrl, user, publicApiKey).(*api.MockedOmAdmin) + } + return api.CurrMockedAdmin + }, func(s string) ([]byte, error) { + return nil, nil + }) + reconciler.client.CreateSecret(s) + return reconciler, manager.Client, initializer +} + +func DefaultOpsManagerBuilder() *omv1.OpsManagerBuilder { + spec := omv1.MongoDBOpsManagerSpec{ + Version: "5.0.0", + AppDB: *omv1.DefaultAppDbBuilder().Build(), + AdminSecret: "om-admin", + } + resource := omv1.MongoDBOpsManager{Spec: spec, ObjectMeta: metav1.ObjectMeta{Name: "test-om", Namespace: mock.TestNamespace}} + return omv1.NewOpsManagerBuilderFromResource(resource) +} + +type MockedInitializer struct { + currentUsers []api.User + expectedAPIError *apierror.Error + expectedOmURL string + t *testing.T + numberOfCalls int +} + +func (o *MockedInitializer) TryCreateUser(omUrl string, omVersion string, user api.User) (api.OpsManagerKeyPair, error) { + o.numberOfCalls++ + assert.Equal(o.t, o.expectedOmURL, omUrl) + + if o.expectedAPIError != nil { + return api.OpsManagerKeyPair{}, o.expectedAPIError + } + // OM logic: any number of users is created. But we cannot of course create the user with the same name + for _, v := range o.currentUsers { + if v.Username == user.Username { + return api.OpsManagerKeyPair{}, apierror.NewErrorWithCode(apierror.UserAlreadyExists) + } + } + o.currentUsers = append(o.currentUsers, user) + + return api.OpsManagerKeyPair{ + PublicKey: user.Username, + PrivateKey: user.Username + "-key", + }, nil +} + +func addKMIPTestResources(client *mock.MockedClient, om omv1.MongoDBOpsManager, mdbName, clientCertificatePrefixName string) { + mdb := mdbv1.NewReplicaSetBuilder().SetBackup(mdbv1.Backup{ + Mode: "enabled", + Encryption: &mdbv1.Encryption{ + Kmip: &mdbv1.KmipConfig{ + Client: v1.KmipClientConfig{ + ClientCertificatePrefix: clientCertificatePrefixName, + }, + }, + }, + }).SetName(mdbName).Build() + _ = client.Create(context.TODO(), mdb) + + mockCert, mockKey := createMockCertAndKeyBytes() + + ca := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: om.Spec.Backup.Encryption.Kmip.Server.CA, + Namespace: om.ObjectMeta.Namespace, + }, + } + ca.Data = map[string]string{} + ca.Data["ca.pem"] = string(mockCert) + _ = client.Create(context.TODO(), ca) + + clientCertificate := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: mdb.GetBackupSpec().Encryption.Kmip.Client.ClientCertificateSecretName(mdb.GetName()), + Namespace: om.ObjectMeta.Namespace, + }, + } + clientCertificate.Data = map[string][]byte{} + clientCertificate.Data["tls.key"] = mockKey + clientCertificate.Data["tls.crt"] = mockCert + _ = client.Create(context.TODO(), clientCertificate) + + clientCertificatePassword := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: mdb.GetBackupSpec().Encryption.Kmip.Client.ClientCertificatePasswordSecretName(mdb.GetName()), + Namespace: om.ObjectMeta.Namespace, + }, + } + clientCertificatePassword.Data = map[string]string{ + mdb.GetBackupSpec().Encryption.Kmip.Client.ClientCertificatePasswordKeyName(): "test", + } + _ = client.Create(context.TODO(), clientCertificatePassword) +} + +func addAppDBTLSResources(client *mock.MockedClient, rs omv1.AppDBSpec, secretName string) { + // Let's create a secret with Certificates and private keys! + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: mock.TestNamespace, + }, + } + + certs := map[string][]byte{} + certs["tls.crt"], certs["tls.key"] = createMockCertAndKeyBytes() + + secret.Data = certs + _ = client.Create(context.TODO(), secret) +} +func addOMTLSResources(client *mock.MockedClient, secretName string) { + // Let's create a secret with Certificates and private keys! + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: mock.TestNamespace, + }, + } + + certs := map[string][]byte{} + certs["tls.crt"], certs["tls.key"] = createMockCertAndKeyBytes() + + secret.Data = certs + _ = client.Create(context.TODO(), secret) +} diff --git a/controllers/operator/mongodbopsmanager_event_handler.go b/controllers/operator/mongodbopsmanager_event_handler.go new file mode 100644 index 000000000..9f9095bfb --- /dev/null +++ b/controllers/operator/mongodbopsmanager_event_handler.go @@ -0,0 +1,30 @@ +package operator + +import ( + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "go.uber.org/zap" + "k8s.io/client-go/util/workqueue" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" +) + +// MongoDBOpsManagerEventHandler extends handler.EnqueueRequestForObject (from controller-runtime) +// which enqueues a Request containing the Name and Namespace of the object that is the source of the Event. +// It is used by the OpsManagerReconciler to reconcile OpsManager resource. +type MongoDBOpsManagerEventHandler struct { + *handler.EnqueueRequestForObject + reconciler interface { + delete(obj interface{}, log *zap.SugaredLogger) + } +} + +// Delete implements EventHandler and it is called when the CR is removed +func (eh *MongoDBOpsManagerEventHandler) Delete(e event.DeleteEvent, _ workqueue.RateLimitingInterface) { + objectKey := kube.ObjectKey(e.Object.GetNamespace(), e.Object.GetName()) + logger := zap.S().With("resource", objectKey) + + zap.S().Infow("Cleaning up OpsManager resource", "resource", e.Object) + eh.reconciler.delete(e.Object, logger) + + logger.Info("Removed Ops Manager resource") +} diff --git a/controllers/operator/mongodbreplicaset_controller.go b/controllers/operator/mongodbreplicaset_controller.go new file mode 100644 index 000000000..f2905d355 --- /dev/null +++ b/controllers/operator/mongodbreplicaset_controller.go @@ -0,0 +1,536 @@ +package operator + +import ( + "context" + "fmt" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/configmap" + "golang.org/x/xerrors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/annotations" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/project" + "k8s.io/apimachinery/pkg/api/errors" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/backup" + "github.com/10gen/ops-manager-kubernetes/controllers/om/replicaset" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/deployment" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/create" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/certs" + + enterprisepem "github.com/10gen/ops-manager-kubernetes/controllers/operator/pem" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/connection" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/construct" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/controlledfeature" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/util/merge" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/util/scale" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/host" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + mdbstatus "github.com/10gen/ops-manager-kubernetes/api/v1/status" + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/agents" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/watch" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/workflow" + "github.com/10gen/ops-manager-kubernetes/pkg/dns" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + util_int "github.com/10gen/ops-manager-kubernetes/pkg/util/int" + "github.com/10gen/ops-manager-kubernetes/pkg/vault" + "github.com/10gen/ops-manager-kubernetes/pkg/vault/vaultwatcher" + + "go.uber.org/zap" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" +) + +// ReconcileMongoDbReplicaSet reconciles a MongoDB with a type of ReplicaSet +type ReconcileMongoDbReplicaSet struct { + *ReconcileCommonController + omConnectionFactory om.ConnectionFactory +} + +var _ reconcile.Reconciler = &ReconcileMongoDbReplicaSet{} + +func newReplicaSetReconciler(mgr manager.Manager, omFunc om.ConnectionFactory) *ReconcileMongoDbReplicaSet { + return &ReconcileMongoDbReplicaSet{ + ReconcileCommonController: newReconcileCommonController(mgr), + omConnectionFactory: omFunc, + } +} + +// Generic Kubernetes Resources +// +kubebuilder:rbac:groups=core,resources=namespaces,verbs=list;watch,namespace=placeholder +// +kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch,namespace=placeholder +// +kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch;create;update,namespace=placeholder +// +kubebuilder:rbac:groups=core,resources={secrets,configmaps},verbs=get;list;watch;create;delete;update,namespace=placeholder +// +kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=create;get;list;watch;delete;update,namespace=placeholder + +// MongoDB Resource +// +kubebuilder:rbac:groups=mongodb.com,resources={mongodb,mongodb/status,mongodb/finalizers},verbs=*,namespace=placeholder + +// Setting up a webhook +// +kubebuilder:rbac:groups=admissionregistration.k8s.io,resources=validatingwebhookconfigurations,verbs=get;create;update;delete + +// Certificate generation +// +kubebuilder:rbac:groups=certificates.k8s.io,resources=certificatesigningrequests,verbs=get;create;list;watch + +// Reconcile reads that state of the cluster for a MongoDbReplicaSet object and makes changes based on the state read +// and what is in the MongoDbReplicaSet.Spec +func (r *ReconcileMongoDbReplicaSet) Reconcile(ctx context.Context, request reconcile.Request) (res reconcile.Result, e error) { + agents.UpgradeAllIfNeeded(r.client, r.SecretClient, r.omConnectionFactory, GetWatchedNamespace()) + + log := zap.S().With("ReplicaSet", request.NamespacedName) + rs := &mdbv1.MongoDB{} + + if reconcileResult, err := r.prepareResourceForReconciliation(request, rs, log); err != nil { + if errors.IsNotFound(err) { + return reconcile.Result{}, nil + } + return reconcileResult, err + } + + log.Info("-> ReplicaSet.Reconcile") + log.Infow("ReplicaSet.Spec", "spec", rs.Spec, "desiredReplicas", scale.ReplicasThisReconciliation(rs), "isScaling", scale.IsStillScaling(rs)) + log.Infow("ReplicaSet.Status", "status", rs.Status) + + if err := rs.ProcessValidationsOnReconcile(nil); err != nil { + return r.updateStatus(rs, workflow.Invalid(err.Error()), log) + } + + projectConfig, credsConfig, err := project.ReadConfigAndCredentials(r.client, r.SecretClient, rs, log) + if err != nil { + return r.updateStatus(rs, workflow.Failed(err), log) + } + + conn, err := connection.PrepareOpsManagerConnection(r.SecretClient, projectConfig, credsConfig, r.omConnectionFactory, rs.Namespace, log) + if err != nil { + return r.updateStatus(rs, workflow.Failed(xerrors.Errorf("Failed to prepare Ops Manager connection: %w", err)), log) + } + + if status := ensureSupportedOpsManagerVersion(conn); status.Phase() != mdbstatus.PhaseRunning { + return r.updateStatus(rs, status, log) + } + + r.SetupCommonWatchers(rs, nil, nil, rs.Name) + + reconcileResult := checkIfHasExcessProcesses(conn, rs, log) + if !reconcileResult.IsOK() { + return r.updateStatus(rs, reconcileResult, log) + } + + if status := validateMongoDBResource(rs, conn); !status.IsOK() { + return r.updateStatus(rs, status, log) + } + + status := certs.EnsureSSLCertsForStatefulSet(r.SecretClient, r.SecretClient, *rs.Spec.Security, certs.ReplicaSetConfig(*rs), log) + if !status.IsOK() { + return r.updateStatus(rs, status, log) + } + + prometheusCertHash, err := certs.EnsureTLSCertsForPrometheus(r.SecretClient, rs.GetNamespace(), rs.GetPrometheus(), certs.Database, log) + if err != nil { + log.Infof("Could not generate certificates for Prometheus: %s", err) + return r.updateStatus(rs, workflow.Pending(err.Error()), log) + } + + if status := controlledfeature.EnsureFeatureControls(*rs, conn, conn.OpsManagerVersion(), log); !status.IsOK() { + return r.updateStatus(rs, status, log) + } + + currentAgentAuthMode, err := conn.GetAgentAuthMode() + if err != nil { + return r.updateStatus(rs, workflow.Failed(err), log) + } + + certConfigurator := certs.ReplicaSetX509CertConfigurator{MongoDB: rs, SecretClient: r.SecretClient} + status = r.ensureX509SecretAndCheckTLSType(certConfigurator, currentAgentAuthMode, log) + if !status.IsOK() { + return r.updateStatus(rs, status, log) + } + + rsCertsConfig := certs.ReplicaSetConfig(*rs) + + var vaultConfig vault.VaultConfiguration + var databaseSecretPath string + if r.VaultClient != nil { + vaultConfig = r.VaultClient.VaultConfig + databaseSecretPath = r.VaultClient.DatabaseSecretPath() + } + + rsConfig := construct.ReplicaSetOptions( + PodEnvVars(newPodVars(conn, projectConfig, rs.Spec.ConnectionSpec)), + CurrentAgentAuthMechanism(currentAgentAuthMode), + CertificateHash(enterprisepem.ReadHashFromSecret(r.SecretClient, rs.Namespace, rsCertsConfig.CertSecretName, databaseSecretPath, log)), + InternalClusterHash(enterprisepem.ReadHashFromSecret(r.SecretClient, rs.Namespace, rsCertsConfig.InternalClusterSecretName, databaseSecretPath, log)), + PrometheusTLSCertHash(prometheusCertHash), + WithVaultConfig(vaultConfig), + WithLabels(rs.Labels), + ) + + caFilePath := util.CAFilePathInContainer + caFilePath = fmt.Sprintf("%s/ca-pem", util.TLSCaMountPath) + + if err := r.reconcileHostnameOverrideConfigMap(log, r.client, *rs); err != nil { + return r.updateStatus(rs, workflow.Failed(xerrors.Errorf("Failed to reconcileHostnameOverrideConfigMap: %w", err)), log) + } + + sts := construct.DatabaseStatefulSet(*rs, rsConfig, log) + if status := ensureRoles(rs.Spec.GetSecurity().Roles, conn, log); !status.IsOK() { + return r.updateStatus(rs, status, log) + } + + if scale.ReplicasThisReconciliation(rs) < rs.Status.Members { + if err := replicaset.PrepareScaleDownFromStatefulSet(conn, sts, rs, log); err != nil { + return r.updateStatus(rs, workflow.Failed(xerrors.Errorf("Failed to prepare Replica Set for scaling down using Ops Manager: %w", err)), log) + } + } + + agentCertSecretName := rs.GetSecurity().AgentClientCertificateSecretName(rs.Name).Name + agentCertSecretName += certs.OperatorGeneratedCertSuffix + + status = workflow.RunInGivenOrder(needToPublishStateFirst(r.client, *rs, rsConfig, log), + func() workflow.Status { + return r.updateOmDeploymentRs(conn, rs.Status.Members, rs, sts, log, caFilePath, agentCertSecretName, prometheusCertHash).OnErrorPrepend("Failed to create/update (Ops Manager reconciliation phase):") + }, + func() workflow.Status { + if err := create.DatabaseInKubernetes(r.client, *rs, sts, construct.ReplicaSetOptions(), log); err != nil { + return workflow.Failed(xerrors.Errorf("Failed to create/update (Kubernetes reconciliation phase): %w", err)) + } + + if status := getStatefulSetStatus(rs.Namespace, rs.Name, r.client); !status.IsOK() { + return status + } + _, _ = r.updateStatus(rs, workflow.Reconciling().WithResourcesNotReady([]mdbstatus.ResourceNotReady{}).WithNoMessage(), log) + + log.Info("Updated StatefulSet for replica set") + return workflow.OK() + + }) + + if !status.IsOK() { + return r.updateStatus(rs, status, log) + } + + if scale.IsStillScaling(rs) { + return r.updateStatus(rs, workflow.Pending("Continuing scaling operation for ReplicaSet %s, desiredMembers=%d, currentMembers=%d", rs.ObjectKey(), rs.DesiredReplicas(), scale.ReplicasThisReconciliation(rs)), log, + mdbstatus.MembersOption(rs)) + } + + annotationsToAdd, err := getAnnotationsForResource(rs) + if err != nil { + return r.updateStatus(rs, workflow.Failed(err), log) + } + + if vault.IsVaultSecretBackend() { + secrets := rs.GetSecretsMountedIntoDBPod() + vaultMap := make(map[string]string) + for _, s := range secrets { + path := fmt.Sprintf("%s/%s/%s", r.VaultClient.DatabaseSecretMetadataPath(), rs.Namespace, s) + vaultMap = merge.StringToStringMap(vaultMap, r.VaultClient.GetSecretAnnotation(path)) + } + path := fmt.Sprintf("%s/%s/%s", r.VaultClient.OperatorScretMetadataPath(), rs.Namespace, rs.Spec.Credentials) + vaultMap = merge.StringToStringMap(vaultMap, r.VaultClient.GetSecretAnnotation(path)) + for k, val := range vaultMap { + annotationsToAdd[k] = val + } + } + + if err := annotations.SetAnnotations(rs, annotationsToAdd, r.client); err != nil { + return r.updateStatus(rs, workflow.Failed(err), log) + } + + log.Infof("Finished reconciliation for MongoDbReplicaSet! %s", completionMessage(conn.BaseURL(), conn.GroupID())) + return r.updateStatus(rs, workflow.OK(), log, mdbstatus.NewBaseUrlOption(deployment.Link(conn.BaseURL(), conn.GroupID())), mdbstatus.MembersOption(rs)) +} + +func getHostnameOverrideConfigMapForReplicaset(mdb mdbv1.MongoDB) corev1.ConfigMap { + data := make(map[string]string) + + if mdb.Spec.DbCommonSpec.GetExternalDomain() != nil { + hostnames, names := dns.GetDNSNames(mdb.Name, "", mdb.GetObjectMeta().GetNamespace(), mdb.GetZZZ_DeprecatedClusterName(), mdb.Spec.Members, mdb.Spec.DbCommonSpec.GetExternalDomain()) + for i := range hostnames { + data[names[i]] = hostnames[i] + } + } + + cm := corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-hostname-override", mdb.Name), + Namespace: mdb.Namespace, + }, + Data: data, + } + return cm +} + +func (r *ReconcileMongoDbReplicaSet) reconcileHostnameOverrideConfigMap(log *zap.SugaredLogger, getUpdateCreator configmap.GetUpdateCreator, mdb mdbv1.MongoDB) error { + if mdb.Spec.DbCommonSpec.GetExternalDomain() == nil { + return nil + } + + cm := getHostnameOverrideConfigMapForReplicaset(mdb) + err := configmap.CreateOrUpdate(getUpdateCreator, cm) + if err != nil && !errors.IsAlreadyExists(err) { + return xerrors.Errorf("failed to create configmap: %s, err: %w", cm.Name, err) + } + log.Infof("Successfully ensured configmap: %s", cm.Name) + + return nil +} + +// AddReplicaSetController creates a new MongoDbReplicaset Controller and adds it to the Manager. The Manager will set fields on the Controller +// and Start it when the Manager is Started. +func AddReplicaSetController(mgr manager.Manager) error { + // Create a new controller + reconciler := newReplicaSetReconciler(mgr, om.NewOpsManagerConnection) + c, err := controller.New(util.MongoDbReplicaSetController, mgr, controller.Options{Reconciler: reconciler}) + if err != nil { + return err + } + + // watch for changes to replica set MongoDB resources + eventHandler := ResourceEventHandler{deleter: reconciler} + // Watch for changes to primary resource MongoDbReplicaSet + err = c.Watch(&source.Kind{Type: &mdbv1.MongoDB{}}, &eventHandler, watch.PredicatesForMongoDB(mdbv1.ReplicaSet)) + + if err != nil { + return err + } + + err = c.Watch(&source.Kind{Type: &appsv1.StatefulSet{}}, &handler.EnqueueRequestForOwner{ + IsController: true, + OwnerType: &mdbv1.MongoDB{}, + }, watch.PredicatesForStatefulSet()) + if err != nil { + return err + } + + err = c.Watch(&source.Kind{Type: &corev1.ConfigMap{}}, + &watch.ResourcesHandler{ResourceType: watch.ConfigMap, TrackedResources: reconciler.WatchedResources}) + if err != nil { + return err + } + + err = c.Watch(&source.Kind{Type: &corev1.Secret{}}, + &watch.ResourcesHandler{ResourceType: watch.Secret, TrackedResources: reconciler.WatchedResources}) + if err != nil { + return err + } + + // if vault secret backend is enabled watch for Vault secret change and trigger reconcile + if vault.IsVaultSecretBackend() { + eventChannel := make(chan event.GenericEvent) + go vaultwatcher.WatchSecretChangeForMDB(zap.S(), eventChannel, reconciler.client, reconciler.VaultClient, mdbv1.ReplicaSet) + + err = c.Watch( + &source.Channel{Source: eventChannel}, + &handler.EnqueueRequestForObject{}, + ) + if err != nil { + zap.S().Errorf("Failed to watch for vault secret changes: %w", err) + } + } + zap.S().Infof("Registered controller %s", util.MongoDbReplicaSetController) + + return nil +} + +// updateOmDeploymentRs performs OM registration operation for the replicaset. So the changes will be finally propagated +// to automation agents in containers +func (r *ReconcileMongoDbReplicaSet) updateOmDeploymentRs(conn om.Connection, membersNumberBefore int, rs *mdbv1.MongoDB, + set appsv1.StatefulSet, log *zap.SugaredLogger, caFilePath string, agentCertSecretName string, prometheusCertHash string) workflow.Status { + + log.Debug("Entering UpdateOMDeployments") + // Only "concrete" RS members should be observed + // - if scaling down, let's observe only members that will remain after scale-down operation + // - if scaling up, observe only current members, because new ones might not exist yet + err := agents.WaitForRsAgentsToRegister(set, util_int.Min(membersNumberBefore, int(*set.Spec.Replicas)), rs.Spec.GetClusterDomain(), conn, log, rs) + if err != nil { + return workflow.Failed(err) + } + + // If current operation is to Disable TLS, then we should the current members of the Replica Set, + // this is, do not scale them up or down util TLS disabling has completed. + shouldLockMembers, err := updateOmDeploymentDisableTLSConfiguration(conn, membersNumberBefore, rs, set, log, caFilePath) + if err != nil { + return workflow.Failed(err) + } + + var updatedMembers int + if shouldLockMembers { + // We should not add or remove members during this run, we'll wait for + // TLS to be completely disabled first. + updatedMembers = membersNumberBefore + } else { + updatedMembers = int(*set.Spec.Replicas) + } + + replicaSet := replicaset.BuildFromStatefulSetWithReplicas(set, rs.GetSpec(), updatedMembers) + processNames := replicaSet.GetProcessNames() + + internalClusterPath := "" + if hash := set.Annotations[util.InternalCertAnnotationKey]; hash != "" { + internalClusterPath = fmt.Sprintf("%s%s", util.InternalClusterAuthMountPath, hash) + } + + status, additionalReconciliationRequired := r.updateOmAuthentication(conn, processNames, rs, agentCertSecretName, caFilePath, internalClusterPath, log) + if !status.IsOK() { + return status + } + + lastRsConfig, err := rs.GetLastAdditionalMongodConfigByType(mdbv1.ReplicaSetConfig) + if err != nil { + return workflow.Failed(err) + } + + p := PrometheusConfiguration{ + prometheus: rs.GetPrometheus(), + conn: conn, + secretsClient: r.SecretClient, + namespace: rs.GetNamespace(), + prometheusCertHash: prometheusCertHash, + } + + err = conn.ReadUpdateDeployment( + func(d om.Deployment) error { + return ReconcileReplicaSetAC(d, replicaSet.Processes, rs.Spec.DbCommonSpec, lastRsConfig.ToMap(), rs.Name, replicaSet, caFilePath, internalClusterPath, &p, log) + }, + log, + ) + + if err != nil { + return workflow.Failed(err) + } + + if err := om.WaitForReadyState(conn, processNames, log); err != nil { + return workflow.Failed(err) + } + + if additionalReconciliationRequired { + return workflow.Pending("Performing multi stage reconciliation") + } + + externalDomain := rs.Spec.DbCommonSpec.GetExternalDomain() + hostsBefore := getAllHostsRs(set, rs.Spec.GetClusterDomain(), membersNumberBefore, externalDomain) + hostsAfter := getAllHostsRs(set, rs.Spec.GetClusterDomain(), scale.ReplicasThisReconciliation(rs), externalDomain) + + if err := host.CalculateDiffAndStopMonitoring(conn, hostsBefore, hostsAfter, log); err != nil { + return workflow.Failed(err) + } + + if status := r.ensureBackupConfigurationAndUpdateStatus(conn, rs, r.SecretClient, log); !status.IsOK() { + return status + } + + log.Info("Updated Ops Manager for replica set") + return workflow.OK() +} + +// updateOmDeploymentDisableTLSConfiguration checks if TLS configuration needs +// to be disabled. In which case it will disable it and inform to the calling +// function. +func updateOmDeploymentDisableTLSConfiguration(conn om.Connection, membersNumberBefore int, rs *mdbv1.MongoDB, set appsv1.StatefulSet, log *zap.SugaredLogger, caFilePath string) (bool, error) { + tlsConfigWasDisabled := false + + err := conn.ReadUpdateDeployment( + func(d om.Deployment) error { + if !d.TLSConfigurationWillBeDisabled(rs.Spec.GetSecurity()) { + return nil + } + + tlsConfigWasDisabled = true + d.ConfigureTLS(rs.Spec.GetSecurity(), caFilePath) + + // configure as many agents/Pods as we currently have, no more (in case + // there's a scale up change at the same time). + replicaSet := replicaset.BuildFromStatefulSetWithReplicas(set, rs.GetSpec(), membersNumberBefore) + + lastConfig, err := rs.GetLastAdditionalMongodConfigByType(mdbv1.ReplicaSetConfig) + if err != nil { + return err + } + + d.MergeReplicaSet(replicaSet, rs.Spec.AdditionalMongodConfig.ToMap(), lastConfig.ToMap(), nil) + + return nil + }, + log, + ) + + return tlsConfigWasDisabled, err +} + +func (r *ReconcileMongoDbReplicaSet) OnDelete(obj runtime.Object, log *zap.SugaredLogger) error { + rs := obj.(*mdbv1.MongoDB) + + projectConfig, credsConfig, err := project.ReadConfigAndCredentials(r.client, r.SecretClient, rs, log) + if err != nil { + return err + } + + log.Infow("Removing replica set from Ops Manager", "config", rs.Spec) + conn, err := connection.PrepareOpsManagerConnection(r.SecretClient, projectConfig, credsConfig, r.omConnectionFactory, rs.Namespace, log) + if err != nil { + return err + } + processNames := make([]string, 0) + err = conn.ReadUpdateDeployment( + func(d om.Deployment) error { + processNames = d.GetProcessNames(om.ReplicaSet{}, rs.Name) + // error means that replica set is not in the deployment - it's ok, and we can proceed (could happen if + // deletion cleanup happened twice and the first one cleaned OM state already) + if e := d.RemoveReplicaSetByName(rs.Name, log); e != nil { + log.Warnf("Failed to remove replica set from automation config: %s", e) + } + + return nil + }, + log, + ) + if err != nil { + return err + } + + if err := om.WaitForReadyState(conn, processNames, log); err != nil { + return err + } + + if rs.Spec.Backup != nil && rs.Spec.Backup.AutoTerminateOnDeletion { + if err := backup.StopBackupIfEnabled(conn, conn, rs.Name, backup.ReplicaSetType, log); err != nil { + return err + } + } + + hostsToRemove, _ := dns.GetDNSNames(rs.Name, rs.ServiceName(), rs.Namespace, rs.Spec.GetClusterDomain(), util.MaxInt(rs.Status.Members, rs.Spec.Members), nil) + log.Infow("Stop monitoring removed hosts in Ops Manager", "removedHosts", hostsToRemove) + + if err = host.StopMonitoring(conn, hostsToRemove, log); err != nil { + return err + } + + if err := r.clearProjectAuthenticationSettings(conn, rs, processNames, log); err != nil { + return err + } + + r.RemoveDependentWatchedResources(rs.ObjectKey()) + + log.Info("Removed replica set from Ops Manager!") + return nil +} + +func getAllHostsRs(set appsv1.StatefulSet, clusterName string, membersCount int, externalDomain *string) []string { + hostnames, _ := dns.GetDnsForStatefulSetReplicasSpecified(set, clusterName, membersCount, externalDomain) + return hostnames +} diff --git a/controllers/operator/mongodbreplicaset_controller_test.go b/controllers/operator/mongodbreplicaset_controller_test.go new file mode 100644 index 000000000..d5c48b602 --- /dev/null +++ b/controllers/operator/mongodbreplicaset_controller_test.go @@ -0,0 +1,848 @@ +package operator + +import ( + "context" + "fmt" + "reflect" + "testing" + "time" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/deployment" + + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + + mdbcv1 "github.com/mongodb/mongodb-kubernetes-operator/api/v1" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/backup" + "github.com/google/uuid" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/construct" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/pem" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/watch" + + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "github.com/10gen/ops-manager-kubernetes/pkg/util/versionutil" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/authentication" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/controlledfeature" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/mock" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/stretchr/testify/assert" + "go.uber.org/zap" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +type ReplicaSetBuilder struct { + *mdbv1.MongoDB +} + +func TestCreateReplicaSet(t *testing.T) { + rs := DefaultReplicaSetBuilder().Build() + + reconciler, client := defaultReplicaSetReconciler(rs) + + checkReconcileSuccessful(t, reconciler, rs, client) + + assert.Len(t, client.GetMapForObject(&corev1.Service{}), 1) + assert.Len(t, client.GetMapForObject(&appsv1.StatefulSet{}), 1) + assert.Equal(t, *client.GetSet(rs.ObjectKey()).Spec.Replicas, int32(3)) + assert.Len(t, client.GetMapForObject(&corev1.Secret{}), 2) + + connection := om.CurrMockedConnection + connection.CheckDeployment(t, deployment.CreateFromReplicaSet(rs), "auth", "ssl") + connection.CheckNumberOfUpdateRequests(t, 2) +} + +func TestReplicaSetServiceName(t *testing.T) { + rs := DefaultReplicaSetBuilder().SetService("rs-svc").Build() + rs.Spec.StatefulSetConfiguration = &mdbcv1.StatefulSetConfiguration{} + rs.Spec.StatefulSetConfiguration.SpecWrapper.Spec.ServiceName = "foo" + + reconciler, client := defaultReplicaSetReconciler(rs) + + checkReconcileSuccessful(t, reconciler, rs, client) + assert.Equal(t, "foo", rs.ServiceName()) + _, err := client.GetService(kube.ObjectKey(rs.Namespace, rs.ServiceName())) + assert.NoError(t, err) +} + +func TestHorizonVerificationTLS(t *testing.T) { + replicaSetHorizons := []mdbv1.MongoDBHorizonConfig{ + {"my-horizon": "my-db.com:12345"}, + {"my-horizon": "my-db.com:12346"}, + {"my-horizon": "my-db.com:12347"}, + } + rs := DefaultReplicaSetBuilder().SetReplicaSetHorizons(replicaSetHorizons).Build() + + reconciler, client := defaultReplicaSetReconciler(rs) + + msg := "TLS must be enabled in order to use replica set horizons" + checkReconcileFailed(t, reconciler, rs, false, msg, client) +} + +func TestHorizonVerificationCount(t *testing.T) { + replicaSetHorizons := []mdbv1.MongoDBHorizonConfig{ + {"my-horizon": "my-db.com:12345"}, + {"my-horizon": "my-db.com:12346"}, + } + rs := DefaultReplicaSetBuilder(). + EnableTLS(). + SetReplicaSetHorizons(replicaSetHorizons). + Build() + + reconciler, client := defaultReplicaSetReconciler(rs) + + msg := "Number of horizons must be equal to number of members in replica set" + checkReconcileFailed(t, reconciler, rs, false, msg, client) +} + +// TestScaleUpReplicaSet verifies scaling up for replica set. Statefulset and OM Deployment must be changed accordingly +func TestScaleUpReplicaSet(t *testing.T) { + rs := DefaultReplicaSetBuilder().SetMembers(3).Build() + + reconciler, client := defaultReplicaSetReconciler(rs) + + checkReconcileSuccessful(t, reconciler, rs, client) + set := &appsv1.StatefulSet{} + _ = client.Get(context.TODO(), mock.ObjectKeyFromApiObject(rs), set) + + // Now scale up to 5 nodes + rs = DefaultReplicaSetBuilder().SetMembers(5).Build() + _ = client.Update(context.TODO(), rs) + + checkReconcileSuccessful(t, reconciler, rs, client) + + updatedSet := &appsv1.StatefulSet{} + _ = client.Get(context.TODO(), mock.ObjectKeyFromApiObject(rs), updatedSet) + + // Statefulset is expected to be the same - only number of replicas changed + set.Spec.Replicas = util.Int32Ref(int32(5)) + assert.Equal(t, set.Spec, updatedSet.Spec) + + connection := om.CurrMockedConnection + connection.CheckDeployment(t, deployment.CreateFromReplicaSet(rs), "auth", "tls") + connection.CheckNumberOfUpdateRequests(t, 4) +} + +func TestExposedExternallyReplicaSet(t *testing.T) { + //given + rs := DefaultReplicaSetBuilder().SetMembers(3).ExposedExternally(nil, nil, nil).Build() + + reconciler, client := defaultReplicaSetReconciler(rs) + + //when + checkReconcileSuccessful(t, reconciler, rs, client) + + // then + // We removed support for single external service named -svc-external (round-robin to all pods). + externalService := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{}, + } + err := client.Get(context.TODO(), types.NamespacedName{Name: rs.Name + "-svc-external", Namespace: rs.Namespace}, externalService) + assert.Error(t, err) + + for podNum := 0; podNum < 3; podNum++ { + err := client.Get(context.TODO(), types.NamespacedName{Name: fmt.Sprintf("%s-%d-svc-external", rs.Name, podNum), Namespace: rs.Namespace}, externalService) + assert.NoError(t, err) + + assert.NoError(t, err) + assert.Equal(t, corev1.ServiceTypeLoadBalancer, externalService.Spec.Type) + assert.Len(t, externalService.Spec.Ports, 1) + assert.Equal(t, "mongodb", externalService.Spec.Ports[0].Name) + assert.Equal(t, 27017, externalService.Spec.Ports[0].TargetPort.IntValue()) + } + + processes := om.CurrMockedConnection.GetProcesses() + require.Len(t, processes, 3) + // check hostnames are pod's headless service FQDNs + for i, process := range processes { + assert.Equal(t, fmt.Sprintf("%s-%d.%s-svc.%s.svc.cluster.local", rs.Name, i, rs.Name, rs.Namespace), process.HostName()) + } +} + +func TestExposedExternallyReplicaSetExternalDomainInHostnames(t *testing.T) { + externalDomain := "example.com" + memberCount := 3 + replicaSetName := "rs" + var expectedHostnames []string + for i := 0; i < memberCount; i++ { + expectedHostnames = append(expectedHostnames, fmt.Sprintf("%s-%d.%s", replicaSetName, i, externalDomain)) + } + + testExposedExternallyReplicaSetExternalDomainInHostnames(t, replicaSetName, memberCount, externalDomain, expectedHostnames) +} + +func testExposedExternallyReplicaSetExternalDomainInHostnames(t *testing.T, replicaSetName string, memberCount int, externalDomain string, expectedHostnames []string) { + rs := DefaultReplicaSetBuilder().SetName(replicaSetName).SetMembers(memberCount).ExposedExternally(nil, nil, &externalDomain).Build() + reconciler, client := defaultReplicaSetReconciler(rs) + + // We set this to mock processes that agents are registering in OM, otherwise reconcile will hang on agent.WaitForRsAgentsToRegister. + // hostnames are already mocked in controllers/operator/mock/mockedkubeclient.go::markStatefulSetsReady, + // but we don't have externalDomain in statefulset there, hence we're setting them here + om.CurrMockedConnection = om.NewMockedOmConnection(nil) + om.CurrMockedConnection.Hostnames = expectedHostnames + + checkReconcileSuccessful(t, reconciler, rs, client) + + processes := om.CurrMockedConnection.GetProcesses() + require.Len(t, processes, memberCount) + // check hostnames are external domain + for i, process := range processes { + // process.HostName is created when building automation config using resource spec + assert.Equal(t, expectedHostnames[i], process.HostName()) + } +} + +func TestExposedExternallyReplicaSetWithNodePort(t *testing.T) { + //given + rs := DefaultReplicaSetBuilder(). + SetMembers(3). + ExposedExternally( + &corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + }, + map[string]string{"test": "test"}, + nil). + Build() + + reconciler, client := defaultReplicaSetReconciler(rs) + + //when + checkReconcileSuccessful(t, reconciler, rs, client) + externalService := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{}, + } + + //then + for podNum := 0; podNum < 3; podNum++ { + err := client.Get(context.TODO(), types.NamespacedName{Name: fmt.Sprintf("%s-%d-svc-external", rs.Name, podNum), Namespace: rs.Namespace}, externalService) + assert.NoError(t, err) + + assert.NoError(t, err) + assert.Equal(t, corev1.ServiceTypeNodePort, externalService.Spec.Type) + assert.Len(t, externalService.Spec.Ports, 1) + assert.Equal(t, "mongodb", externalService.Spec.Ports[0].Name) + assert.Equal(t, 27017, externalService.Spec.Ports[0].TargetPort.IntValue()) + } +} + +func TestCreateReplicaSet_TLS(t *testing.T) { + rs := DefaultReplicaSetBuilder().SetMembers(3).EnableTLS().SetTLSCA("custom-ca").Build() + + reconciler, client := defaultReplicaSetReconciler(rs) + addKubernetesTlsResources(client, rs) + client.ApproveAllCSRs() + checkReconcileSuccessful(t, reconciler, rs, client) + + processes := om.CurrMockedConnection.GetProcesses() + assert.Len(t, processes, 3) + for _, v := range processes { + assert.NotNil(t, v.TLSConfig()) + assert.Len(t, v.TLSConfig(), 2) + assert.Equal(t, fmt.Sprintf("%s/%s", util.TLSCertMountPath, pem.ReadHashFromSecret(reconciler.SecretClient, rs.Namespace, fmt.Sprintf("%s-cert", rs.Name), "", zap.S())), v.TLSConfig()["certificateKeyFile"]) + assert.Equal(t, "requireTLS", v.TLSConfig()["mode"]) + } + + sslConfig := om.CurrMockedConnection.GetTLS() + assert.Equal(t, fmt.Sprintf("%s/%s", util.TLSCaMountPath, "ca-pem"), sslConfig["CAFilePath"]) + assert.Equal(t, "OPTIONAL", sslConfig["clientCertificateMode"]) +} + +// TestUpdateDeploymentTLSConfiguration a combination of tests checking that: +// +// TLS Disabled -> TLS Disabled: should not lock members +// TLS Disabled -> TLS Enabled: should not lock members +// TLS Enabled -> TLS Enabled: should not lock members +// TLS Enabled -> TLS Disabled: *should lock members* +func TestUpdateDeploymentTLSConfiguration(t *testing.T) { + rsWithTLS := mdbv1.NewReplicaSetBuilder().SetSecurityTLSEnabled().Build() + rsNoTLS := mdbv1.NewReplicaSetBuilder().Build() + deploymentWithTLS := deployment.CreateFromReplicaSet(rsWithTLS) + deploymentNoTLS := deployment.CreateFromReplicaSet(rsNoTLS) + stsWithTLS := construct.DatabaseStatefulSet(*rsWithTLS, construct.ReplicaSetOptions(construct.GetPodEnvOptions()), nil) + stsNoTLS := construct.DatabaseStatefulSet(*rsNoTLS, construct.ReplicaSetOptions(construct.GetPodEnvOptions()), nil) + + // TLS Disabled -> TLS Disabled + shouldLockMembers, err := updateOmDeploymentDisableTLSConfiguration(om.NewMockedOmConnection(deploymentNoTLS), 3, rsNoTLS, stsNoTLS, zap.S(), util.CAFilePathInContainer) + assert.NoError(t, err) + assert.False(t, shouldLockMembers) + + // TLS Disabled -> TLS Enabled + shouldLockMembers, err = updateOmDeploymentDisableTLSConfiguration(om.NewMockedOmConnection(deploymentNoTLS), 3, rsWithTLS, stsWithTLS, zap.S(), util.CAFilePathInContainer) + assert.NoError(t, err) + assert.False(t, shouldLockMembers) + + // TLS Enabled -> TLS Enabled + shouldLockMembers, err = updateOmDeploymentDisableTLSConfiguration(om.NewMockedOmConnection(deploymentWithTLS), 3, rsWithTLS, stsWithTLS, zap.S(), util.CAFilePathInContainer) + assert.NoError(t, err) + assert.False(t, shouldLockMembers) + + // TLS Enabled -> TLS Disabled + shouldLockMembers, err = updateOmDeploymentDisableTLSConfiguration(om.NewMockedOmConnection(deploymentWithTLS), 3, rsNoTLS, stsNoTLS, zap.S(), util.CAFilePathInContainer) + assert.NoError(t, err) + assert.True(t, shouldLockMembers) +} + +// TestCreateDeleteReplicaSet checks that no state is left in OpsManager on removal of the replicaset +func TestCreateDeleteReplicaSet(t *testing.T) { + // First we need to create a replicaset + rs := DefaultReplicaSetBuilder().Build() + + reconciler, client := defaultReplicaSetReconciler(rs) + + checkReconcileSuccessful(t, reconciler, rs, client) + omConn := om.CurrMockedConnection + omConn.CleanHistory() + + // Now delete it + assert.NoError(t, reconciler.OnDelete(rs, zap.S())) + + // Operator doesn't mutate K8s state, so we don't check its changes, only OM + omConn.CheckResourcesDeleted(t) + + omConn.CheckOrderOfOperations(t, + reflect.ValueOf(omConn.ReadUpdateDeployment), reflect.ValueOf(omConn.ReadAutomationStatus), + reflect.ValueOf(omConn.GetHosts), reflect.ValueOf(omConn.RemoveHost)) + +} + +func TestX509IsNotEnabledWithOlderVersionsOfOpsManager(t *testing.T) { + rs := DefaultReplicaSetBuilder().EnableAuth().EnableTLS().SetTLSCA("custom-ca").SetAuthModes([]string{util.X509}).Build() + reconciler, client := defaultReplicaSetReconciler(rs) + reconciler.omConnectionFactory = func(context *om.OMContext) om.Connection { + conn := om.NewEmptyMockedOmConnection(context) + + // make the mocked connection return an error behaving as an older version of Ops Manager + conn.(*om.MockedOmConnection).UpdateMonitoringAgentConfigFunc = func(mac *om.MonitoringAgentConfig, log *zap.SugaredLogger) (bytes []byte, e error) { + return nil, xerrors.Errorf("some error. Detail: %s", util.MethodNotAllowed) + } + return conn + } + + addKubernetesTlsResources(client, rs) + checkReconcileFailed(t, reconciler, rs, true, "unable to configure X509 with this version of Ops Manager", client) +} + +func TestReplicaSetScramUpgradeDowngrade(t *testing.T) { + rs := DefaultReplicaSetBuilder().SetVersion("4.0.0").EnableAuth().SetAuthModes([]string{"SCRAM"}).Build() + + reconciler, client := defaultReplicaSetReconciler(rs) + + checkReconcileSuccessful(t, reconciler, rs, client) + + ac, _ := om.CurrMockedConnection.ReadAutomationConfig() + assert.Contains(t, ac.Auth.AutoAuthMechanisms, string(authentication.ScramSha256)) + + // downgrade to version that will not use SCRAM-SHA-256 + rs.Spec.Version = "3.6.9" + + _ = client.Update(context.TODO(), rs) + + checkReconcileFailed(t, reconciler, rs, false, "Unable to downgrade to SCRAM-SHA-1 when SCRAM-SHA-256 has been enabled", client) +} + +func TestReplicaSetCustomPodSpecTemplate(t *testing.T) { + podSpec := corev1.PodSpec{NodeName: "some-node-name", + Hostname: "some-host-name", + Containers: []corev1.Container{{ + Name: "my-custom-container", + Image: "my-custom-image", + VolumeMounts: []corev1.VolumeMount{{ + Name: "my-volume-mount", + }}, + }}, + RestartPolicy: corev1.RestartPolicyAlways} + + rs := DefaultReplicaSetBuilder().EnableTLS().SetTLSCA("custom-ca").SetPodSpecTemplate(corev1.PodTemplateSpec{ + Spec: podSpec, + }).Build() + + reconciler, client := defaultReplicaSetReconciler(rs) + + addKubernetesTlsResources(client, rs) + + checkReconcileSuccessful(t, reconciler, rs, client) + + // read the stateful set that was created by the operator + statefulSet, err := client.GetStatefulSet(mock.ObjectKeyFromApiObject(rs)) + assert.NoError(t, err) + + assertPodSpecSts(t, &statefulSet, podSpec.NodeName, podSpec.Hostname, podSpec.RestartPolicy) + + podSpecTemplate := statefulSet.Spec.Template.Spec + assert.Len(t, podSpecTemplate.Containers, 2, "Should have 2 containers now") + assert.Equal(t, util.DatabaseContainerName, podSpecTemplate.Containers[0].Name, "Database container should always be first") + assert.Equal(t, "my-custom-container", podSpecTemplate.Containers[1].Name, "Custom container should be second") +} + +func TestFeatureControlPolicyAndTagAddedWithNewerOpsManager(t *testing.T) { + rs := DefaultReplicaSetBuilder().Build() + + reconciler, client := defaultReplicaSetReconciler(rs) + reconciler.omConnectionFactory = func(context *om.OMContext) om.Connection { + context.Version = versionutil.OpsManagerVersion{ + VersionString: "5.0.0", + } + conn := om.NewEmptyMockedOmConnection(context) + return conn + } + + checkReconcileSuccessful(t, reconciler, rs, client) + + mockedConn := om.CurrMockedConnection + cf, _ := mockedConn.GetControlledFeature() + + assert.Len(t, cf.Policies, 3) + assert.Equal(t, cf.ManagementSystem.Version, util.OperatorVersion) + assert.Equal(t, cf.ManagementSystem.Name, util.OperatorName) + + project := mockedConn.FindGroup("my-project") + assert.Contains(t, project.Tags, util.OmGroupExternallyManagedTag) +} + +func TestFeatureControlPolicyNoAuthNewerOpsManager(t *testing.T) { + rsBuilder := DefaultReplicaSetBuilder() + rsBuilder.Spec.Security = nil + + rs := rsBuilder.Build() + + reconciler, client := defaultReplicaSetReconciler(rs) + reconciler.omConnectionFactory = func(context *om.OMContext) om.Connection { + context.Version = versionutil.OpsManagerVersion{ + VersionString: "5.0.0", + } + conn := om.NewEmptyMockedOmConnection(context) + return conn + } + + checkReconcileSuccessful(t, reconciler, rs, client) + + mockedConn := om.CurrMockedConnection + cf, _ := mockedConn.GetControlledFeature() + + assert.Len(t, cf.Policies, 2) + assert.Equal(t, cf.ManagementSystem.Version, util.OperatorVersion) + assert.Equal(t, cf.ManagementSystem.Name, util.OperatorName) + assert.Equal(t, cf.Policies[0].PolicyType, controlledfeature.ExternallyManaged) + assert.Equal(t, cf.Policies[1].PolicyType, controlledfeature.DisableMongodVersion) + assert.Len(t, cf.Policies[0].DisabledParams, 0) +} + +func TestScalingScalesOneMemberAtATime_WhenScalingDown(t *testing.T) { + rs := DefaultReplicaSetBuilder().SetMembers(5).Build() + reconciler, client := defaultReplicaSetReconciler(rs) + // perform initial reconciliation so we are not creating a new resource + checkReconcileSuccessful(t, reconciler, rs, client) + + // scale down from 5 to 3 members + rs.Spec.Members = 3 + + err := client.Update(context.TODO(), rs) + assert.NoError(t, err) + + res, err := reconciler.Reconcile(context.TODO(), requestFromObject(rs)) + + assert.NoError(t, err) + assert.Equal(t, time.Duration(10000000000), res.RequeueAfter, "Scaling from 5 -> 4 should enqueue another reconciliation") + + assertCorrectNumberOfMembersAndProcesses(t, 4, rs, client, "We should have updated the status with the intermediate value of 4") + + res, err = reconciler.Reconcile(context.TODO(), requestFromObject(rs)) + assert.NoError(t, err) + assert.Equal(t, time.Duration(0), res.RequeueAfter, "Once we reach the target value, we should not scale anymore") + + assertCorrectNumberOfMembersAndProcesses(t, 3, rs, client, "The members should now be set to the final desired value") + +} + +func TestScalingScalesOneMemberAtATime_WhenScalingUp(t *testing.T) { + rs := DefaultReplicaSetBuilder().SetMembers(1).Build() + reconciler, client := defaultReplicaSetReconciler(rs) + // perform initial reconciliation so we are not creating a new resource + checkReconcileSuccessful(t, reconciler, rs, client) + + // scale up from 1 to 3 members + rs.Spec.Members = 3 + + err := client.Update(context.TODO(), rs) + assert.NoError(t, err) + + res, err := reconciler.Reconcile(context.TODO(), requestFromObject(rs)) + assert.NoError(t, err) + + assert.Equal(t, time.Duration(10000000000), res.RequeueAfter, "Scaling from 1 -> 3 should enqueue another reconciliation") + + assertCorrectNumberOfMembersAndProcesses(t, 2, rs, client, "We should have updated the status with the intermediate value of 2") + + res, err = reconciler.Reconcile(context.TODO(), requestFromObject(rs)) + assert.NoError(t, err) + + assertCorrectNumberOfMembersAndProcesses(t, 3, rs, client, "Once we reach the target value, we should not scale anymore") +} + +func TestReplicaSetPortIsConfigurable_WithAdditionalMongoConfig(t *testing.T) { + config := mdbv1.NewAdditionalMongodConfig("net.port", 30000) + rs := mdbv1.NewReplicaSetBuilder(). + SetNamespace(mock.TestNamespace). + SetAdditionalConfig(config). + SetConnectionSpec(testConnectionSpec()). + Build() + + reconciler, client := defaultReplicaSetReconciler(rs) + + checkReconcileSuccessful(t, reconciler, rs, client) + + svc, err := client.GetService(kube.ObjectKey(rs.Namespace, rs.ServiceName())) + assert.NoError(t, err) + assert.Equal(t, int32(30000), svc.Spec.Ports[0].Port) +} + +// TestReplicaSet_ConfigMapAndSecretWatched verifies that config map and secret are added to the internal +// map that allows to watch them for changes +func TestReplicaSet_ConfigMapAndSecretWatched(t *testing.T) { + rs := DefaultReplicaSetBuilder().Build() + + reconciler, client := defaultReplicaSetReconciler(rs) + + checkReconcileSuccessful(t, reconciler, rs, client) + + expected := map[watch.Object][]types.NamespacedName{ + {ResourceType: watch.ConfigMap, Resource: kube.ObjectKey(mock.TestNamespace, mock.TestProjectConfigMapName)}: {kube.ObjectKey(mock.TestNamespace, rs.Name)}, + {ResourceType: watch.Secret, Resource: kube.ObjectKey(mock.TestNamespace, rs.Spec.Credentials)}: {kube.ObjectKey(mock.TestNamespace, rs.Name)}, + } + + assert.Equal(t, reconciler.WatchedResources, expected) +} + +// TestTLSResourcesAreWatchedAndUnwatched verifies that TLS config map and secret are added to the internal +// map that allows to watch them for changes +func TestTLSResourcesAreWatchedAndUnwatched(t *testing.T) { + rs := DefaultReplicaSetBuilder().EnableTLS().SetTLSCA("custom-ca").Build() + + reconciler, client := defaultReplicaSetReconciler(rs) + + addKubernetesTlsResources(client, rs) + checkReconcileSuccessful(t, reconciler, rs, client) + + expected := map[watch.Object][]types.NamespacedName{ + {ResourceType: watch.ConfigMap, Resource: kube.ObjectKey(mock.TestNamespace, mock.TestProjectConfigMapName)}: {kube.ObjectKey(mock.TestNamespace, rs.Name)}, + {ResourceType: watch.Secret, Resource: kube.ObjectKey(mock.TestNamespace, rs.Spec.Credentials)}: {kube.ObjectKey(mock.TestNamespace, rs.Name)}, + {ResourceType: watch.ConfigMap, Resource: kube.ObjectKey(mock.TestNamespace, "custom-ca")}: {kube.ObjectKey(mock.TestNamespace, rs.Name)}, + {ResourceType: watch.Secret, Resource: kube.ObjectKey(mock.TestNamespace, rs.GetName()+"-cert")}: {kube.ObjectKey(mock.TestNamespace, rs.Name)}, + } + + assert.Equal(t, reconciler.WatchedResources, expected) + + rs.Spec.Security.TLSConfig.Enabled = false + checkReconcileSuccessful(t, reconciler, rs, client) + + expected = map[watch.Object][]types.NamespacedName{ + {ResourceType: watch.ConfigMap, Resource: kube.ObjectKey(mock.TestNamespace, mock.TestProjectConfigMapName)}: {kube.ObjectKey(mock.TestNamespace, rs.Name)}, + {ResourceType: watch.Secret, Resource: kube.ObjectKey(mock.TestNamespace, rs.Spec.Credentials)}: {kube.ObjectKey(mock.TestNamespace, rs.Name)}, + } + + assert.Equal(t, reconciler.WatchedResources, expected) +} + +func TestBackupConfiguration_ReplicaSet(t *testing.T) { + rs := mdbv1.NewReplicaSetBuilder(). + SetNamespace(mock.TestNamespace). + SetConnectionSpec(testConnectionSpec()). + SetBackup(mdbv1.Backup{ + Mode: "enabled", + }). + Build() + + reconciler, client := defaultReplicaSetReconciler(rs) + + uuidStr := uuid.New().String() + // configure backup for this project in Ops Manager in the mocked connection + om.CurrMockedConnection = om.NewMockedOmConnection(om.NewDeployment()) + om.CurrMockedConnection.UpdateBackupConfig(&backup.Config{ + ClusterId: uuidStr, + Status: backup.Inactive, + }) + + // add corresponding host cluster. + om.CurrMockedConnection.BackupHostClusters[uuidStr] = &backup.HostCluster{ + ReplicaSetName: rs.Name, + ClusterName: rs.Name, + TypeName: "REPLICA_SET", + } + + t.Run("Backup can be started", func(t *testing.T) { + checkReconcileSuccessful(t, reconciler, rs, client) + + configResponse, _ := om.CurrMockedConnection.ReadBackupConfigs() + assert.Len(t, configResponse.Configs, 1) + + config := configResponse.Configs[0] + + assert.Equal(t, backup.Started, config.Status) + assert.Equal(t, uuidStr, config.ClusterId) + assert.Equal(t, "PRIMARY", config.SyncSource) + }) + + t.Run("Backup snapshot schedule tests", backupSnapshotScheduleTests(rs, client, reconciler, uuidStr)) + + t.Run("Backup can be stopped", func(t *testing.T) { + rs.Spec.Backup.Mode = "disabled" + err := client.Update(context.TODO(), rs) + assert.NoError(t, err) + + checkReconcileSuccessful(t, reconciler, rs, client) + + configResponse, _ := om.CurrMockedConnection.ReadBackupConfigs() + assert.Len(t, configResponse.Configs, 1) + + config := configResponse.Configs[0] + + assert.Equal(t, backup.Stopped, config.Status) + assert.Equal(t, uuidStr, config.ClusterId) + assert.Equal(t, "PRIMARY", config.SyncSource) + }) + + t.Run("Backup can be terminated", func(t *testing.T) { + rs.Spec.Backup.Mode = "terminated" + err := client.Update(context.TODO(), rs) + assert.NoError(t, err) + + checkReconcileSuccessful(t, reconciler, rs, client) + + configResponse, _ := om.CurrMockedConnection.ReadBackupConfigs() + assert.Len(t, configResponse.Configs, 1) + + config := configResponse.Configs[0] + + assert.Equal(t, backup.Terminating, config.Status) + assert.Equal(t, uuidStr, config.ClusterId) + assert.Equal(t, "PRIMARY", config.SyncSource) + }) +} + +// assertCorrectNumberOfMembersAndProcesses ensures that both the mongodb resource and the Ops Manager deployment +// have the correct number of processes/replicas at each stage of the scaling operation +func assertCorrectNumberOfMembersAndProcesses(t *testing.T, expected int, mdb *mdbv1.MongoDB, client *mock.MockedClient, msg string) { + err := client.Get(context.TODO(), mdb.ObjectKey(), mdb) + assert.NoError(t, err) + assert.Equal(t, expected, mdb.Status.Members, msg) + dep, err := om.CurrMockedConnection.ReadDeployment() + assert.NoError(t, err) + assert.Len(t, dep.ProcessesCopy(), expected) +} + +// defaultReplicaSetReconciler is the replica set reconciler used in unit test. It "adds" necessary +// additional K8s objects (rs, connection config map and secrets) necessary for reconciliation +// so it's possible to call 'reconcileAppDB()' on it right away +func defaultReplicaSetReconciler(rs *mdbv1.MongoDB) (*ReconcileMongoDbReplicaSet, *mock.MockedClient) { + return replicaSetReconcilerWithConnection(rs, om.NewEmptyMockedOmConnection) +} + +func replicaSetReconcilerWithConnection(rs *mdbv1.MongoDB, connectionFunc func(ctx *om.OMContext) om.Connection) (*ReconcileMongoDbReplicaSet, *mock.MockedClient) { + manager := mock.NewManager(rs) + manager.Client.AddDefaultMdbConfigResources() + + return newReplicaSetReconciler(manager, connectionFunc), manager.Client +} + +// newDefaultPodSpec creates pod spec with default values,sets only the topology key and persistence sizes, +// seems we shouldn't set CPU and Memory if they are not provided by user +func newDefaultPodSpec() mdbv1.MongoDbPodSpec { + podSpecWrapper := mdbv1.NewEmptyPodSpecWrapperBuilder(). + SetPodAntiAffinityTopologyKey(util.DefaultAntiAffinityTopologyKey). + SetSinglePersistence(mdbv1.NewPersistenceBuilder(util.DefaultMongodStorageSize)). + SetMultiplePersistence(mdbv1.NewPersistenceBuilder(util.DefaultMongodStorageSize), + mdbv1.NewPersistenceBuilder(util.DefaultJournalStorageSize), + mdbv1.NewPersistenceBuilder(util.DefaultLogsStorageSize)). + Build() + + return podSpecWrapper.MongoDbPodSpec +} + +// TODO remove in favor of '/api/mongodbbuilder.go' +func DefaultReplicaSetBuilder() *ReplicaSetBuilder { + podSpec := newDefaultPodSpec() + spec := mdbv1.MongoDbSpec{ + DbCommonSpec: mdbv1.DbCommonSpec{ + Version: "4.0.0", + Persistent: util.BooleanRef(false), + ConnectionSpec: mdbv1.ConnectionSpec{ + SharedConnectionSpec: mdbv1.SharedConnectionSpec{ + OpsManagerConfig: &mdbv1.PrivateCloudConfig{ + ConfigMapRef: mdbv1.ConfigMapRef{ + Name: mock.TestProjectConfigMapName, + }, + }, + }, + Credentials: mock.TestCredentialsSecretName, + }, + ResourceType: mdbv1.ReplicaSet, + Security: &mdbv1.Security{ + TLSConfig: &mdbv1.TLSConfig{}, + Authentication: &mdbv1.Authentication{ + Modes: []string{}, + }, + Roles: []mdbv1.MongoDbRole{}, + }, + }, + Members: 3, + PodSpec: &podSpec, + } + rs := &mdbv1.MongoDB{Spec: spec, ObjectMeta: metav1.ObjectMeta{Name: "temple", Namespace: mock.TestNamespace}} + return &ReplicaSetBuilder{rs} +} + +func (b *ReplicaSetBuilder) SetName(name string) *ReplicaSetBuilder { + b.Name = name + return b +} +func (b *ReplicaSetBuilder) SetVersion(version string) *ReplicaSetBuilder { + b.Spec.Version = version + return b +} +func (b *ReplicaSetBuilder) SetPersistent(p *bool) *ReplicaSetBuilder { + b.Spec.Persistent = p + return b +} + +func (b *ReplicaSetBuilder) SetPodSpec(podSpec *mdbv1.MongoDbPodSpec) *ReplicaSetBuilder { + b.Spec.PodSpec = podSpec + return b +} + +func (b *ReplicaSetBuilder) SetMembers(m int) *ReplicaSetBuilder { + b.Spec.Members = m + return b +} + +func (b *ReplicaSetBuilder) SetSecurity(security mdbv1.Security) *ReplicaSetBuilder { + b.Spec.Security = &security + return b +} + +func (b *ReplicaSetBuilder) SetService(name string) *ReplicaSetBuilder { + b.Spec.Service = name + return b +} + +func (b *ReplicaSetBuilder) SetAuthentication(auth *mdbv1.Authentication) *ReplicaSetBuilder { + if b.Spec.Security == nil { + b.Spec.Security = &mdbv1.Security{} + } + b.Spec.Security.Authentication = auth + return b +} + +func (b *ReplicaSetBuilder) SetRoles(roles []mdbv1.MongoDbRole) *ReplicaSetBuilder { + if b.Spec.Security == nil { + b.Spec.Security = &mdbv1.Security{} + } + b.Spec.Security.Roles = roles + return b +} + +func (b *ReplicaSetBuilder) EnableAuth() *ReplicaSetBuilder { + b.Spec.Security.Authentication.Enabled = true + return b +} + +func (b *ReplicaSetBuilder) AgentAuthMode(agentMode string) *ReplicaSetBuilder { + if b.Spec.Security == nil { + b.Spec.Security = &mdbv1.Security{} + } + + if b.Spec.Security.Authentication == nil { + b.Spec.Security.Authentication = &mdbv1.Authentication{} + } + b.Spec.Security.Authentication.Agents = mdbv1.AgentAuthentication{Mode: agentMode} + return b +} + +func (b *ReplicaSetBuilder) LDAP(ldap mdbv1.Ldap) *ReplicaSetBuilder { + b.Spec.Security.Authentication.Ldap = &ldap + return b +} + +func (b *ReplicaSetBuilder) SetAuthModes(modes []string) *ReplicaSetBuilder { + b.Spec.Security.Authentication.Modes = modes + return b +} + +func (b *ReplicaSetBuilder) EnableX509InternalClusterAuth() *ReplicaSetBuilder { + b.Spec.Security.Authentication.InternalCluster = util.X509 + return b +} + +func (b *ReplicaSetBuilder) SetReplicaSetHorizons(replicaSetHorizons []mdbv1.MongoDBHorizonConfig) *ReplicaSetBuilder { + if b.Spec.Connectivity == nil { + b.Spec.Connectivity = &mdbv1.MongoDBConnectivity{} + } + b.Spec.Connectivity.ReplicaSetHorizons = replicaSetHorizons + return b +} + +func (b *ReplicaSetBuilder) EnableTLS() *ReplicaSetBuilder { + if b.Spec.Security == nil || b.Spec.Security.TLSConfig == nil { + b.SetSecurity(mdbv1.Security{TLSConfig: &mdbv1.TLSConfig{}}) + } + b.Spec.Security.TLSConfig.Enabled = true + return b +} + +func (b *ReplicaSetBuilder) SetTLSCA(ca string) *ReplicaSetBuilder { + if b.Spec.Security == nil || b.Spec.Security.TLSConfig == nil { + b.SetSecurity(mdbv1.Security{TLSConfig: &mdbv1.TLSConfig{}}) + } + b.Spec.Security.TLSConfig.CA = ca + return b +} + +func (b *ReplicaSetBuilder) EnableX509() *ReplicaSetBuilder { + b.Spec.Security.Authentication.Enabled = true + b.Spec.Security.Authentication.Modes = append(b.Spec.Security.Authentication.Modes, util.X509) + return b +} + +func (b *ReplicaSetBuilder) EnableSCRAM() *ReplicaSetBuilder { + b.Spec.Security.Authentication.Enabled = true + b.Spec.Security.Authentication.Modes = append(b.Spec.Security.Authentication.Modes, util.SCRAM) + return b +} + +func (b *ReplicaSetBuilder) EnableLDAP() *ReplicaSetBuilder { + b.Spec.Security.Authentication.Enabled = true + b.Spec.Security.Authentication.Modes = append(b.Spec.Security.Authentication.Modes, util.LDAP) + return b +} + +func (b *ReplicaSetBuilder) SetPodSpecTemplate(spec corev1.PodTemplateSpec) *ReplicaSetBuilder { + if b.Spec.PodSpec == nil { + b.Spec.PodSpec = &mdbv1.MongoDbPodSpec{} + } + b.Spec.PodSpec.PodTemplateWrapper.PodTemplate = &spec + return b +} + +func (b *ReplicaSetBuilder) Build() *mdbv1.MongoDB { + b.InitDefaults() + return b.MongoDB.DeepCopy() +} + +func (b *ReplicaSetBuilder) ExposedExternally(specOverride *corev1.ServiceSpec, annotationsOverride map[string]string, externalDomain *string) *ReplicaSetBuilder { + b.Spec.ExternalAccessConfiguration = &mdbv1.ExternalAccessConfiguration{} + b.Spec.ExternalAccessConfiguration.ExternalDomain = externalDomain + if specOverride != nil { + b.Spec.ExternalAccessConfiguration.ExternalService.SpecWrapper = &mdbv1.ServiceSpecWrapper{Spec: *specOverride} + } + if len(annotationsOverride) > 0 { + b.Spec.ExternalAccessConfiguration.ExternalService.Annotations = annotationsOverride + } + return b +} diff --git a/controllers/operator/mongodbresource_event_handler.go b/controllers/operator/mongodbresource_event_handler.go new file mode 100644 index 000000000..507dcac51 --- /dev/null +++ b/controllers/operator/mongodbresource_event_handler.go @@ -0,0 +1,37 @@ +package operator + +import ( + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "go.uber.org/zap" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/util/workqueue" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" +) + +// Deleter cleans up any state required upon deletion of a resource. +type Deleter interface { + OnDelete(obj runtime.Object, log *zap.SugaredLogger) error +} + +// ResourceEventHandler is a custom event handler that extends the +// handler.EnqueueRequestForObject event handler. It overrides the Delete +// method used to clean up custom resources when a deletion event happens. +// This results in a single, synchronous attempt to clean up the resource +// rather than an asynchronous one. +type ResourceEventHandler struct { + *handler.EnqueueRequestForObject + deleter Deleter +} + +func (h *ResourceEventHandler) Delete(e event.DeleteEvent, _ workqueue.RateLimitingInterface) { + objectKey := kube.ObjectKey(e.Object.GetNamespace(), e.Object.GetName()) + logger := zap.S().With("resource", objectKey) + + zap.S().Infow("Cleaning up Resource", "resource", e.Object) + if err := h.deleter.OnDelete(e.Object, logger); err != nil { + logger.Errorf("Resource removed from Kubernetes, but failed to clean some state in Ops Manager: %s", err) + return + } + logger.Info("Removed Resource from Kubernetes and Ops Manager") +} diff --git a/controllers/operator/mongodbshardedcluster_controller.go b/controllers/operator/mongodbshardedcluster_controller.go new file mode 100644 index 000000000..a51ca8a04 --- /dev/null +++ b/controllers/operator/mongodbshardedcluster_controller.go @@ -0,0 +1,1017 @@ +package operator + +import ( + "context" + "fmt" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/annotations" + "golang.org/x/xerrors" + + "k8s.io/apimachinery/pkg/api/errors" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/project" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/10gen/ops-manager-kubernetes/pkg/dns" + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "github.com/10gen/ops-manager-kubernetes/pkg/statefulset" + "github.com/10gen/ops-manager-kubernetes/pkg/vault" + "github.com/10gen/ops-manager-kubernetes/pkg/vault/vaultwatcher" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/backup" + "github.com/10gen/ops-manager-kubernetes/controllers/om/replicaset" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/deployment" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/create" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/certs" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/construct" + enterprisepem "github.com/10gen/ops-manager-kubernetes/controllers/operator/pem" + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/connection" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/controlledfeature" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/util/merge" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/util/scale" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/host" + appsv1 "k8s.io/api/apps/v1" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + mdbstatus "github.com/10gen/ops-manager-kubernetes/api/v1/status" + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/agents" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/watch" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/workflow" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" +) + +// ReconcileMongoDbShardedCluster +type ReconcileMongoDbShardedCluster struct { + *ReconcileCommonController + configSrvScaler shardedClusterScaler + mongosScaler shardedClusterScaler + mongodsPerShardScaler shardedClusterScaler + omConnectionFactory om.ConnectionFactory +} + +func newShardedClusterReconciler(mgr manager.Manager, omFunc om.ConnectionFactory) *ReconcileMongoDbShardedCluster { + return &ReconcileMongoDbShardedCluster{ + ReconcileCommonController: newReconcileCommonController(mgr), + omConnectionFactory: omFunc, + } +} + +func (r *ReconcileMongoDbShardedCluster) Reconcile(ctx context.Context, request reconcile.Request) (res reconcile.Result, e error) { + agents.UpgradeAllIfNeeded(r.client, r.SecretClient, r.omConnectionFactory, GetWatchedNamespace()) + + log := zap.S().With("ShardedCluster", request.NamespacedName) + sc := &mdbv1.MongoDB{} + + reconcileResult, err := r.prepareResourceForReconciliation(request, sc, log) + if err != nil { + if errors.IsNotFound(err) { + return reconcile.Result{}, nil + } + return reconcileResult, err + } + + if err := sc.ProcessValidationsOnReconcile(nil); err != nil { + return r.updateStatus(sc, workflow.Invalid(err.Error()), log) + } + + r.initCountsForThisReconciliation(*sc) + + log.Info("-> ShardedCluster.Reconcile") + log.Infow("ShardedCluster.Spec", "spec", sc.Spec) + log.Infow("ShardedCluster.Status", "status", sc.Status) + log.Infow("ShardedClusterScaling", "mongosScaler", r.mongosScaler, "configSrvScaler", r.configSrvScaler, "mongodsPerShardScaler", r.mongodsPerShardScaler, "desiredShards", sc.Spec.ShardCount, "currentShards", sc.Status.ShardCount) + + projectConfig, credsConfig, err := project.ReadConfigAndCredentials(r.client, r.SecretClient, sc, log) + if err != nil { + return r.updateStatus(sc, workflow.Failed(err), log) + } + + conn, err := connection.PrepareOpsManagerConnection(r.SecretClient, projectConfig, credsConfig, r.omConnectionFactory, sc.Namespace, log) + if err != nil { + return r.updateStatus(sc, workflow.Failed(err), log) + } + + status := r.doShardedClusterProcessing(sc, conn, projectConfig, log) + if !status.IsOK() || status.Phase() == mdbstatus.PhaseUnsupported { + return r.updateStatus(sc, status, log) + } + + if scale.AnyAreStillScaling(r.mongodsPerShardScaler, r.configSrvScaler, r.mongosScaler) { + return r.updateStatus(sc, workflow.Pending("Continuing scaling operation for ShardedCluster %s mongodsPerShardCount %+v, mongosCount %+v, configServerCount %+v", + sc.ObjectKey(), + r.mongodsPerShardScaler, + r.mongosScaler, + r.configSrvScaler, + ), + log, + mdbstatus.MongodsPerShardOption(r.mongodsPerShardScaler), + mdbstatus.ConfigServerOption(r.configSrvScaler), + mdbstatus.MongosCountOption(r.mongosScaler), + ) + } + + // only remove any stateful sets if we are scaling down + // Note: we should only remove unused stateful sets once we are fully complete + // removing members 1 at a time. + if sc.Spec.ShardCount < sc.Status.ShardCount { + r.removeUnusedStatefulsets(sc, log) + } + + annotationsToAdd, err := getAnnotationsForResource(sc) + if err != nil { + return r.updateStatus(sc, workflow.Failed(err), log) + } + + if vault.IsVaultSecretBackend() { + secrets := sc.GetSecretsMountedIntoDBPod() + vaultMap := make(map[string]string) + for _, s := range secrets { + path := fmt.Sprintf("%s/%s/%s", r.VaultClient.DatabaseSecretMetadataPath(), sc.Namespace, s) + vaultMap = merge.StringToStringMap(vaultMap, r.VaultClient.GetSecretAnnotation(path)) + } + path := fmt.Sprintf("%s/%s/%s", r.VaultClient.OperatorScretMetadataPath(), sc.Namespace, sc.Spec.Credentials) + vaultMap = merge.StringToStringMap(vaultMap, r.VaultClient.GetSecretAnnotation(path)) + for k, val := range vaultMap { + annotationsToAdd[k] = val + } + } + if err := annotations.SetAnnotations(sc, annotationsToAdd, r.client); err != nil { + return r.updateStatus(sc, workflow.Failed(err), log) + } + + log.Infof("Finished reconciliation for Sharded Cluster! %s", completionMessage(conn.BaseURL(), conn.GroupID())) + return r.updateStatus(sc, status, log, mdbstatus.NewBaseUrlOption(deployment.Link(conn.BaseURL(), conn.GroupID())), + mdbstatus.MongodsPerShardOption(r.mongodsPerShardScaler), mdbstatus.ConfigServerOption(r.configSrvScaler), mdbstatus.MongosCountOption(r.mongosScaler)) +} + +func (r *ReconcileMongoDbShardedCluster) initCountsForThisReconciliation(sc mdbv1.MongoDB) { + r.mongosScaler = shardedClusterScaler{CurrentMembers: sc.Status.MongosCount, DesiredMembers: sc.Spec.MongosCount} + r.configSrvScaler = shardedClusterScaler{CurrentMembers: sc.Status.ConfigServerCount, DesiredMembers: sc.Spec.ConfigServerCount} + r.mongodsPerShardScaler = shardedClusterScaler{CurrentMembers: sc.Status.MongodsPerShardCount, DesiredMembers: sc.Spec.MongodsPerShardCount} +} + +func (r *ReconcileMongoDbShardedCluster) getConfigSrvCountThisReconciliation() int { + return scale.ReplicasThisReconciliation(r.configSrvScaler) +} + +func (r *ReconcileMongoDbShardedCluster) getMongosCountThisReconciliation() int { + return scale.ReplicasThisReconciliation(r.mongosScaler) +} + +func (r *ReconcileMongoDbShardedCluster) getMongodsPerShardCountThisReconciliation() int { + return scale.ReplicasThisReconciliation(r.mongodsPerShardScaler) +} + +// implements all the logic to do the sharded cluster thing +func (r *ReconcileMongoDbShardedCluster) doShardedClusterProcessing(obj interface{}, conn om.Connection, projectConfig mdbv1.ProjectConfig, log *zap.SugaredLogger) workflow.Status { + log.Info("ShardedCluster.doShardedClusterProcessing") + sc := obj.(*mdbv1.MongoDB) + + if status := ensureSupportedOpsManagerVersion(conn); status.Phase() != mdbstatus.PhaseRunning { + return status + } + + r.SetupCommonWatchers(sc, getTLSSecretNames(sc), getInternalAuthSecretNames(sc), sc.Name) + + reconcileResult := checkIfHasExcessProcesses(conn, sc, log) + if !reconcileResult.IsOK() { + return reconcileResult + } + + security := sc.Spec.Security + // TODO move to webhook validations + if security.Authentication != nil && security.Authentication.Enabled && security.Authentication.IsX509Enabled() && !sc.Spec.GetSecurity().IsTLSEnabled() { + return workflow.Invalid("cannot have a non-tls deployment when x509 authentication is enabled") + } + + currentAgentAuthMode, err := conn.GetAgentAuthMode() + if err != nil { + return workflow.Failed(err) + } + + podEnvVars := newPodVars(conn, projectConfig, sc.Spec.ConnectionSpec) + + status, certSecretTypesForSTS := r.ensureSSLCertificates(sc, log) + if !status.IsOK() { + return status + } + + prometheusCertHash, err := certs.EnsureTLSCertsForPrometheus(r.SecretClient, sc.GetNamespace(), sc.GetPrometheus(), certs.Database, log) + if err != nil { + return workflow.Failed(xerrors.Errorf("Could not generate certificates for Prometheus: %w", err)) + } + + opts := deploymentOptions{ + podEnvVars: podEnvVars, + currentAgentAuthMode: currentAgentAuthMode, + certTLSType: certSecretTypesForSTS, + } + + if err = r.prepareScaleDownShardedCluster(conn, sc, opts, log); err != nil { + return workflow.Failed(xerrors.Errorf("failed to perform scale down preliminary actions: %w", err)) + } + + if status := validateMongoDBResource(sc, conn); !status.IsOK() { + return status + } + + // Ensures that all sharded cluster certificates are either of Opaque type (old design) + // or are all of kubernetes.io/tls type + // and save the value for future use + allCertsType, err := getCertTypeForAllShardedClusterCertificates(certSecretTypesForSTS) + if err != nil { + return workflow.Failed(err) + } + + caFilePath := util.CAFilePathInContainer + if allCertsType == corev1.SecretTypeTLS { + caFilePath = fmt.Sprintf("%s/ca-pem", util.TLSCaMountPath) + } + + if status := controlledfeature.EnsureFeatureControls(*sc, conn, conn.OpsManagerVersion(), log); !status.IsOK() { + return status + } + + certConfigurator := certs.ShardedSetX509CertConfigurator{ + MongoDB: sc, + MongodsPerShardScaler: r.mongodsPerShardScaler, + MongosScaler: r.mongosScaler, + ConfigSrvScaler: r.configSrvScaler, + SecretClient: r.SecretClient, + } + + status = r.ensureX509SecretAndCheckTLSType(certConfigurator, currentAgentAuthMode, log) + if !status.IsOK() { + return status + } + + if status := ensureRoles(sc.Spec.GetSecurity().Roles, conn, log); !status.IsOK() { + return status + } + + agentCertSecretName := sc.GetSecurity().AgentClientCertificateSecretName(sc.Name).Name + + opts = deploymentOptions{ + podEnvVars: podEnvVars, + currentAgentAuthMode: currentAgentAuthMode, + caFilePath: caFilePath, + agentCertSecretName: agentCertSecretName, + prometheusCertHash: prometheusCertHash, + } + allConfigs := r.getAllConfigs(*sc, opts, log) + + agentCertSecretName += certs.OperatorGeneratedCertSuffix + + status = workflow.RunInGivenOrder(anyStatefulSetNeedsToPublishState(*sc, r.client, allConfigs, log), + func() workflow.Status { + return r.updateOmDeploymentShardedCluster(conn, sc, opts, log).OnErrorPrepend("Failed to create/update (Ops Manager reconciliation phase):") + }, + func() workflow.Status { + return r.createKubernetesResources(sc, opts, log).OnErrorPrepend("Failed to create/update (Kubernetes reconciliation phase):") + }) + + if !status.IsOK() { + return status + } + return reconcileResult +} + +func getTLSSecretNames(sc *mdbv1.MongoDB) func() []string { + return func() []string { + var secretNames []string + secretNames = append(secretNames, + sc.GetSecurity().MemberCertificateSecretName(sc.MongosRsName()), + sc.GetSecurity().MemberCertificateSecretName(sc.ConfigRsName()), + ) + for i := 0; i < sc.Spec.ShardCount; i++ { + secretNames = append(secretNames, sc.GetSecurity().MemberCertificateSecretName(sc.ShardRsName(i))) + } + return secretNames + } +} + +func getInternalAuthSecretNames(sc *mdbv1.MongoDB) func() []string { + return func() []string { + var secretNames []string + secretNames = append(secretNames, + sc.GetSecurity().InternalClusterAuthSecretName(sc.MongosRsName()), + sc.GetSecurity().InternalClusterAuthSecretName(sc.ConfigRsName()), + ) + for i := 0; i < sc.Spec.ShardCount; i++ { + secretNames = append(secretNames, sc.GetSecurity().InternalClusterAuthSecretName(sc.ShardRsName(i))) + } + return secretNames + } +} + +// getCertTypeForAllShardedClusterCertificates checks whether all certificates secret are of the same type and returns it. +func getCertTypeForAllShardedClusterCertificates(certTypes map[string]bool) (corev1.SecretType, error) { + if len(certTypes) == 0 { + return corev1.SecretTypeTLS, nil + } + valueSlice := make([]bool, 0, len(certTypes)) + for _, v := range certTypes { + valueSlice = append(valueSlice, v) + } + curTypeIsTLS := valueSlice[0] + for i := 1; i < len(valueSlice); i++ { + if valueSlice[i] != curTypeIsTLS { + return corev1.SecretTypeOpaque, xerrors.Errorf("TLS Certificates for Sharded cluster must all be of the same type - either kubernetes.io/tls or secrets containing a concatenated pem file") + } + } + if curTypeIsTLS { + return corev1.SecretTypeTLS, nil + } + return corev1.SecretTypeOpaque, nil +} + +// anyStatefulSetNeedsToPublishState checks to see if any stateful set +// of the given sharded cluster needs to publish state to Ops Manager before updating Kubernetes resources +func anyStatefulSetNeedsToPublishState(sc mdbv1.MongoDB, getter ConfigMapStatefulSetSecretGetter, configs []func(mdb mdbv1.MongoDB) construct.DatabaseStatefulSetOptions, log *zap.SugaredLogger) bool { + for _, cf := range configs { + if needToPublishStateFirst(getter, sc, cf, log) { + return true + } + } + return false +} + +// getAllConfigs returns a list of all the configuration functions associated with the Sharded Cluster. +// This includes the Mongos, the Config Server and all Shards +func (r ReconcileMongoDbShardedCluster) getAllConfigs(sc mdbv1.MongoDB, opts deploymentOptions, log *zap.SugaredLogger) []func(mdb mdbv1.MongoDB) construct.DatabaseStatefulSetOptions { + allConfigs := make([]func(mdb mdbv1.MongoDB) construct.DatabaseStatefulSetOptions, 0) + for i := 0; i < sc.Spec.ShardCount; i++ { + allConfigs = append(allConfigs, r.getShardOptions(sc, i, opts, log)) + } + allConfigs = append(allConfigs, r.getConfigServerOptions(sc, opts, log)) + allConfigs = append(allConfigs, r.getMongosOptions(sc, opts, log)) + return allConfigs +} + +func (r *ReconcileMongoDbShardedCluster) removeUnusedStatefulsets(sc *mdbv1.MongoDB, log *zap.SugaredLogger) { + statefulsetsToRemove := sc.Status.ShardCount - sc.Spec.ShardCount + shardsCount := sc.Status.MongodbShardedClusterSizeConfig.ShardCount + + // we iterate over last 'statefulsetsToRemove' shards if any + for i := shardsCount - statefulsetsToRemove; i < shardsCount; i++ { + key := kube.ObjectKey(sc.Namespace, sc.ShardRsName(i)) + err := r.client.DeleteStatefulSet(key) + if err != nil { + // Most of all the error won't be recoverable, also our sharded cluster is in good shape - we can just warn + // the error and leave the cleanup work for the admins + log.Warnf("Failed to delete the statefulset %s: %s", key, err) + } + log.Infof("Removed statefulset %s as it's was removed from sharded cluster", key) + } +} + +func (r *ReconcileMongoDbShardedCluster) ensureSSLCertificates(s *mdbv1.MongoDB, log *zap.SugaredLogger) (workflow.Status, map[string]bool) { + tlsConfig := s.Spec.GetTLSConfig() + + certSecretTypes := map[string]bool{} + if tlsConfig == nil || !s.Spec.GetSecurity().IsTLSEnabled() { + return workflow.OK(), certSecretTypes + } + + var status workflow.Status + status = workflow.OK() + mongosCert := certs.MongosConfig(*s, r.mongosScaler) + tStatus := certs.EnsureSSLCertsForStatefulSet(r.SecretClient, r.SecretClient, *s.Spec.Security, mongosCert, log) + certSecretTypes[mongosCert.CertSecretName] = true + status = status.Merge(tStatus) + + configSrvCert := certs.ConfigSrvConfig(*s, r.configSrvScaler) + tStatus = certs.EnsureSSLCertsForStatefulSet(r.SecretClient, r.SecretClient, *s.Spec.Security, configSrvCert, log) + certSecretTypes[configSrvCert.CertSecretName] = true + status = status.Merge(tStatus) + + for i := 0; i < s.Spec.ShardCount; i++ { + shardCert := certs.ShardConfig(*s, i, r.mongodsPerShardScaler) + tStatus := certs.EnsureSSLCertsForStatefulSet(r.SecretClient, r.SecretClient, *s.Spec.Security, shardCert, log) + certSecretTypes[shardCert.CertSecretName] = true + status = status.Merge(tStatus) + } + + return status, certSecretTypes +} + +// createKubernetesResources creates all Kubernetes objects that are specified in 'state' parameter. +// This function returns errorStatus if any errors occured or pendingStatus if the statefulsets are not +// ready yet +// Note, that it doesn't remove any existing shards - this will be done later +func (r *ReconcileMongoDbShardedCluster) createKubernetesResources(s *mdbv1.MongoDB, opts deploymentOptions, log *zap.SugaredLogger) workflow.Status { + configSrvOpts := r.getConfigServerOptions(*s, opts, log) + configSrvSts := construct.DatabaseStatefulSet(*s, configSrvOpts, nil) + if err := create.DatabaseInKubernetes(r.client, *s, configSrvSts, configSrvOpts, log); err != nil { + return workflow.Failed(xerrors.Errorf("Failed to create Config Server Stateful Set: %w", err)) + } + if status := getStatefulSetStatus(s.Namespace, s.ConfigRsName(), r.client); !status.IsOK() { + return status + } + _, _ = r.updateStatus(s, workflow.Reconciling().WithResourcesNotReady([]mdbstatus.ResourceNotReady{}).WithNoMessage(), log) + + log.Infow("Created/updated StatefulSet for config servers", "name", s.ConfigRsName(), "servers count", configSrvSts.Spec.Replicas) + + shardsNames := make([]string, s.Spec.ShardCount) + + for i := 0; i < s.Spec.ShardCount; i++ { + shardsNames[i] = s.ShardRsName(i) + shardOpts := r.getShardOptions(*s, i, opts, log) + shardSts := construct.DatabaseStatefulSet(*s, shardOpts, nil) + + if err := create.DatabaseInKubernetes(r.client, *s, shardSts, shardOpts, log); err != nil { + return workflow.Failed(xerrors.Errorf("Failed to create Stateful Set for shard %s: %w", shardsNames[i], err)) + } + if status := getStatefulSetStatus(s.Namespace, shardsNames[i], r.client); !status.IsOK() { + return status + } + _, _ = r.updateStatus(s, workflow.Reconciling().WithResourcesNotReady([]mdbstatus.ResourceNotReady{}).WithNoMessage(), log) + } + + log.Infow("Created/updated Stateful Sets for shards in Kubernetes", "shards", shardsNames) + + mongosOpts := r.getMongosOptions(*s, opts, log) + mongosSts := construct.DatabaseStatefulSet(*s, mongosOpts, nil) + + if err := create.DatabaseInKubernetes(r.client, *s, mongosSts, mongosOpts, log); err != nil { + return workflow.Failed(xerrors.Errorf("Failed to create Mongos Stateful Set: %w", err)) + } + + if status := getStatefulSetStatus(s.Namespace, s.MongosRsName(), r.client); !status.IsOK() { + return status + } + _, _ = r.updateStatus(s, workflow.Reconciling().WithResourcesNotReady([]mdbstatus.ResourceNotReady{}).WithNoMessage(), log) + + log.Infow("Created/updated StatefulSet for mongos servers", "name", s.MongosRsName(), "servers count", mongosSts.Spec.Replicas) + return workflow.OK() +} + +// OnDelete tries to complete a Deletion reconciliation event +func (r *ReconcileMongoDbShardedCluster) OnDelete(obj runtime.Object, log *zap.SugaredLogger) error { + sc := obj.(*mdbv1.MongoDB) + + projectConfig, credsConfig, err := project.ReadConfigAndCredentials(r.client, r.SecretClient, sc, log) + if err != nil { + return err + } + + conn, err := connection.PrepareOpsManagerConnection(r.SecretClient, projectConfig, credsConfig, r.omConnectionFactory, sc.Namespace, log) + if err != nil { + return err + } + + processNames := make([]string, 0) + err = conn.ReadUpdateDeployment( + func(d om.Deployment) error { + processNames = d.GetProcessNames(om.ShardedCluster{}, sc.Name) + if e := d.RemoveShardedClusterByName(sc.Name, log); e != nil { + log.Warnf("Failed to remove sharded cluster from automation config: %s", e) + } + return nil + }, + log, + ) + if err != nil { + return err + } + + if err := om.WaitForReadyState(conn, processNames, log); err != nil { + return err + } + + if sc.Spec.Backup != nil && sc.Spec.Backup.AutoTerminateOnDeletion { + if err := backup.StopBackupIfEnabled(conn, conn, sc.Name, backup.ReplicaSetType, log); err != nil { + return err + } + } + + currentCount := mdbv1.MongodbShardedClusterSizeConfig{ + MongodsPerShardCount: sc.Status.MongodsPerShardCount, + MongosCount: sc.Status.MongosCount, + ShardCount: sc.Status.ShardCount, + ConfigServerCount: sc.Status.ConfigServerCount, + } + + desiredCountThisReconciliation := mdbv1.MongodbShardedClusterSizeConfig{ + MongodsPerShardCount: r.getMongodsPerShardCountThisReconciliation(), + MongosCount: r.getMongosCountThisReconciliation(), + ShardCount: sc.Spec.ShardCount, + ConfigServerCount: r.getConfigSrvCountThisReconciliation(), + } + + sizeConfig := getMaxShardedClusterSizeConfig(desiredCountThisReconciliation, currentCount) + hostsToRemove := getAllHosts(sc, sizeConfig) + log.Infow("Stop monitoring removed hosts in Ops Manager", "hostsToBeRemoved", hostsToRemove) + + if err = host.StopMonitoring(conn, hostsToRemove, log); err != nil { + return err + } + + if err := r.clearProjectAuthenticationSettings(conn, sc, processNames, log); err != nil { + return err + } + + r.RemoveDependentWatchedResources(sc.ObjectKey()) + + log.Info("Removed sharded cluster from Ops Manager!") + + return nil +} + +func AddShardedClusterController(mgr manager.Manager) error { + reconciler := newShardedClusterReconciler(mgr, om.NewOpsManagerConnection) + options := controller.Options{Reconciler: reconciler} + c, err := controller.New(util.MongoDbShardedClusterController, mgr, options) + if err != nil { + return err + } + + // watch for changes to sharded cluster MongoDB resources + eventHandler := ResourceEventHandler{deleter: reconciler} + err = c.Watch(&source.Kind{Type: &mdbv1.MongoDB{}}, &eventHandler, watch.PredicatesForMongoDB(mdbv1.ShardedCluster)) + if err != nil { + return err + } + + err = c.Watch(&source.Kind{Type: &corev1.ConfigMap{}}, + &watch.ResourcesHandler{ResourceType: watch.ConfigMap, TrackedResources: reconciler.WatchedResources}) + if err != nil { + return err + } + + err = c.Watch(&source.Kind{Type: &corev1.Secret{}}, + &watch.ResourcesHandler{ResourceType: watch.Secret, TrackedResources: reconciler.WatchedResources}) + if err != nil { + return err + } + // if vault secret backend is enabled watch for Vault secret change and trigger reconcile + if vault.IsVaultSecretBackend() { + eventChannel := make(chan event.GenericEvent) + go vaultwatcher.WatchSecretChangeForMDB(zap.S(), eventChannel, reconciler.client, reconciler.VaultClient, mdbv1.ShardedCluster) + + err = c.Watch( + &source.Channel{Source: eventChannel}, + &handler.EnqueueRequestForObject{}, + ) + if err != nil { + zap.S().Errorf("Failed to watch for vault secret changes: %w", err) + } + } + zap.S().Infof("Registered controller %s", util.MongoDbShardedClusterController) + + return nil +} + +func (r *ReconcileMongoDbShardedCluster) prepareScaleDownShardedCluster(omClient om.Connection, sc *mdbv1.MongoDB, opts deploymentOptions, log *zap.SugaredLogger) error { + membersToScaleDown := make(map[string][]string) + clusterName := sc.Spec.GetClusterDomain() + + // Scaledown amount of replicas in ConfigServer + if r.isConfigServerScaleDown() { + sts := construct.DatabaseStatefulSet(*sc, r.getConfigServerOptions(*sc, opts, log), nil) + _, podNames := dns.GetDnsForStatefulSetReplicasSpecified(sts, clusterName, sc.Status.ConfigServerCount, nil) + membersToScaleDown[sc.ConfigRsName()] = podNames[r.getConfigSrvCountThisReconciliation():sc.Status.ConfigServerCount] + } + + // Scaledown size of each shard + if r.isShardsSizeScaleDown() { + for i := 0; i < sc.Spec.ShardCount; i++ { + sts := construct.DatabaseStatefulSet(*sc, r.getShardOptions(*sc, i, opts, log), nil) + _, podNames := dns.GetDnsForStatefulSetReplicasSpecified(sts, clusterName, sc.Status.MongodsPerShardCount, nil) + membersToScaleDown[sc.ShardRsName(i)] = podNames[r.getMongodsPerShardCountThisReconciliation():sc.Status.MongodsPerShardCount] + } + } + + if len(membersToScaleDown) > 0 { + if err := replicaset.PrepareScaleDownFromMap(omClient, membersToScaleDown, log); err != nil { + return err + } + } + return nil +} + +func (r *ReconcileMongoDbShardedCluster) isConfigServerScaleDown() bool { + return scale.ReplicasThisReconciliation(r.configSrvScaler) < r.configSrvScaler.CurrentReplicas() +} + +func (r *ReconcileMongoDbShardedCluster) isShardsSizeScaleDown() bool { + return scale.ReplicasThisReconciliation(r.mongodsPerShardScaler) < r.mongodsPerShardScaler.CurrentReplicas() +} + +// deploymentOptions contains fields required for creating the OM deployment for the Sharded Cluster. +type deploymentOptions struct { + podEnvVars *env.PodEnvVars + currentAgentAuthMode string + caFilePath string + agentCertSecretName string + certTLSType map[string]bool + finalizing bool + processNames []string + prometheusCertHash string +} + +// updateOmDeploymentShardedCluster performs OM registration operation for the sharded cluster. So the changes will be finally propagated +// to automation agents in containers +// Note that the process may have two phases (if shards number is decreased): +// phase 1: "drain" the shards: remove them from sharded cluster, put replica set names to "draining" array, not remove +// replica sets and processes, wait for agents to reach the goal +// phase 2: remove the "junk" replica sets and their processes, wait for agents to reach the goal. +// The logic is designed to be idempotent: if the reconciliation is retried the controller will never skip the phase 1 +// until the agents have performed draining +func (r *ReconcileMongoDbShardedCluster) updateOmDeploymentShardedCluster(conn om.Connection, sc *mdbv1.MongoDB, opts deploymentOptions, log *zap.SugaredLogger) workflow.Status { + err := r.waitForAgentsToRegister(sc, conn, opts, log, sc) + if err != nil { + return workflow.Failed(err) + } + + dep, err := conn.ReadDeployment() + if err != nil { + return workflow.Failed(err) + } + + opts.finalizing = false + opts.processNames = dep.GetProcessNames(om.ShardedCluster{}, sc.Name) + + processNames, shardsRemoving, status := r.publishDeployment(conn, sc, &opts, log) + + if !status.IsOK() { + return status + } + + if err = om.WaitForReadyState(conn, processNames, log); err != nil { + if shardsRemoving { + return workflow.Pending("automation agents haven't reached READY state: shards removal in progress") + } + return workflow.Failed(err) + } + + if shardsRemoving { + opts.finalizing = true + + log.Infof("Some shards were removed from the sharded cluster, we need to remove them from the deployment completely") + processNames, _, status = r.publishDeployment(conn, sc, &opts, log) + if !status.IsOK() { + return status + } + + if err = om.WaitForReadyState(conn, processNames, log); err != nil { + return workflow.Failed(xerrors.Errorf("automation agents haven't reached READY state while cleaning replica set and processes: %w", err)) + } + } + + currentCount := mdbv1.MongodbShardedClusterSizeConfig{ + MongodsPerShardCount: sc.Status.MongodsPerShardCount, + MongosCount: sc.Status.MongosCount, + ShardCount: sc.Status.ShardCount, + ConfigServerCount: sc.Status.ConfigServerCount, + } + + desiredCount := mdbv1.MongodbShardedClusterSizeConfig{ + MongodsPerShardCount: r.getMongodsPerShardCountThisReconciliation(), + MongosCount: r.getMongosCountThisReconciliation(), + ShardCount: sc.Spec.ShardCount, + ConfigServerCount: r.getConfigSrvCountThisReconciliation(), + } + + currentHosts := getAllHosts(sc, currentCount) + wantedHosts := getAllHosts(sc, desiredCount) + + if err = host.CalculateDiffAndStopMonitoring(conn, currentHosts, wantedHosts, log); err != nil { + return workflow.Failed(err) + } + + if status := r.ensureBackupConfigurationAndUpdateStatus(conn, sc, r.SecretClient, log); !status.IsOK() { + return status + } + + log.Info("Updated Ops Manager for sharded cluster") + return workflow.OK() +} + +func (r *ReconcileMongoDbShardedCluster) publishDeployment(conn om.Connection, sc *mdbv1.MongoDB, opts *deploymentOptions, log *zap.SugaredLogger) ([]string, bool, workflow.Status) { + + // mongos + sts := construct.DatabaseStatefulSet(*sc, r.getMongosOptions(*sc, *opts, log), nil) + mongosInternalClusterPath := statefulset.GetFilePathFromAnnotationOrDefault(sts, util.InternalCertAnnotationKey, util.InternalClusterAuthMountPath, "") + mongosMemberCertPath := statefulset.GetFilePathFromAnnotationOrDefault(sts, certs.CertHashAnnotationKey, util.TLSCertMountPath, util.PEMKeyFilePathInContainer) + mongosProcesses := createMongosProcesses(sts, sc, mongosMemberCertPath) + + // config server + configSvrSts := construct.DatabaseStatefulSet(*sc, r.getConfigServerOptions(*sc, *opts, log), nil) + configInternalClusterPath := statefulset.GetFilePathFromAnnotationOrDefault(configSvrSts, util.InternalCertAnnotationKey, util.InternalClusterAuthMountPath, "") + configMemberCertPath := statefulset.GetFilePathFromAnnotationOrDefault(configSvrSts, certs.CertHashAnnotationKey, util.TLSCertMountPath, util.PEMKeyFilePathInContainer) + configRs := buildReplicaSetFromProcesses(configSvrSts.Name, createConfigSrvProcesses(configSvrSts, sc, configMemberCertPath), sc) + + // shards + shards := make([]om.ReplicaSetWithProcesses, sc.Spec.ShardCount) + shardsInternalClusterPath := make([]string, len(shards)) + for i := 0; i < sc.Spec.ShardCount; i++ { + shardSts := construct.DatabaseStatefulSet(*sc, r.getShardOptions(*sc, i, *opts, log), nil) + shardsInternalClusterPath[i] = statefulset.GetFilePathFromAnnotationOrDefault(shardSts, util.InternalCertAnnotationKey, util.InternalClusterAuthMountPath, "") + shardMemberCertPath := statefulset.GetFilePathFromAnnotationOrDefault(shardSts, certs.CertHashAnnotationKey, util.TLSCertMountPath, util.PEMKeyFilePathInContainer) + shards[i] = buildReplicaSetFromProcesses(shardSts.Name, createShardProcesses(shardSts, sc, shardMemberCertPath), sc) + } + + // updateOmAuthentication normally takes care of the certfile rotation code, but since sharded-cluster is special pertaining multiple clusterfiles, we code this part here for now. + // We can look into unifying this into updateOmAuthentication at a later stage. + if err := conn.ReadUpdateDeployment(func(d om.Deployment) error { + setInternalAuthClusterFileIfItHasChanged(d, sc.GetSecurity().GetInternalClusterAuthenticationMode(), sc.Name, configInternalClusterPath, mongosInternalClusterPath, shardsInternalClusterPath) + return nil + }, log); err != nil { + return nil, false, workflow.Failed(err) + } + + status, additionalReconciliationRequired := r.updateOmAuthentication(conn, opts.processNames, sc, opts.agentCertSecretName, opts.caFilePath, "", log) + if !status.IsOK() { + return nil, false, status + } + + var finalProcesses []string + shardsRemoving := false + err := conn.ReadUpdateDeployment( + func(d om.Deployment) error { + // it is not possible to disable internal cluster authentication once enabled + allProcesses := getAllProcesses(shards, configRs, mongosProcesses) + if sc.Spec.Security.GetInternalClusterAuthenticationMode() == "" && d.ExistingProcessesHaveInternalClusterAuthentication(allProcesses) { + return xerrors.Errorf("cannot disable x509 internal cluster authentication") + } + numberOfOtherMembers := d.GetNumberOfExcessProcesses(sc.Name) + if numberOfOtherMembers > 0 { + return xerrors.Errorf("cannot have more than 1 MongoDB Cluster per project (see https://docs.mongodb.com/kubernetes-operator/stable/tutorial/migrate-to-single-resource/)") + } + + lastConfigServerConf, err := sc.GetLastAdditionalMongodConfigByType(mdbv1.ConfigServerConfig) + if err != nil { + return err + } + + lastShardServerConf, err := sc.GetLastAdditionalMongodConfigByType(mdbv1.ShardConfig) + if err != nil { + return err + } + + lastMongosServerConf, err := sc.GetLastAdditionalMongodConfigByType(mdbv1.MongosConfig) + if err != nil { + return err + } + + mergeOpts := om.DeploymentShardedClusterMergeOptions{ + Name: sc.Name, + MongosProcesses: mongosProcesses, + ConfigServerRs: configRs, + Shards: shards, + Finalizing: opts.finalizing, + ConfigServerAdditionalOptionsPrev: lastConfigServerConf.ToMap(), + ConfigServerAdditionalOptionsDesired: sc.Spec.ConfigSrvSpec.AdditionalMongodConfig.ToMap(), + ShardAdditionalOptionsPrev: lastShardServerConf.ToMap(), + ShardAdditionalOptionsDesired: sc.Spec.ShardSpec.AdditionalMongodConfig.ToMap(), + MongosAdditionalOptionsPrev: lastMongosServerConf.ToMap(), + MongosAdditionalOptionsDesired: sc.Spec.MongosSpec.AdditionalMongodConfig.ToMap(), + } + + if shardsRemoving, err = d.MergeShardedCluster(mergeOpts); err != nil { + return err + } + + d.AddMonitoringAndBackup(log, sc.Spec.GetSecurity().IsTLSEnabled(), opts.caFilePath) + d.ConfigureTLS(sc.Spec.GetSecurity(), opts.caFilePath) + + setupInternalClusterAuth(d, sc.Name, sc.GetSecurity().GetInternalClusterAuthenticationMode(), + configInternalClusterPath, mongosInternalClusterPath, shardsInternalClusterPath) + + _ = UpdatePrometheus(&d, conn, sc.GetPrometheus(), r.SecretClient, sc.GetNamespace(), opts.prometheusCertHash, log) + + finalProcesses = d.GetProcessNames(om.ShardedCluster{}, sc.Name) + + return nil + }, + log, + ) + + if err != nil { + return nil, shardsRemoving, workflow.Failed(err) + } + + if err := om.WaitForReadyState(conn, opts.processNames, log); err != nil { + return nil, shardsRemoving, workflow.Failed(err) + } + + if additionalReconciliationRequired { + return nil, shardsRemoving, workflow.Pending("Performing multi stage reconciliation") + } + + return finalProcesses, shardsRemoving, workflow.OK() +} + +func setInternalAuthClusterFileIfItHasChanged(d om.Deployment, internalAuthMode string, name string, configInternalClusterPath string, mongosInternalClusterPath string, shardsInternalClusterPath []string) { + d.SetInternalClusterFilePathOnlyIfItThePathHasChanged(d.GetShardedClusterConfigProcessNames(name), configInternalClusterPath, internalAuthMode) + d.SetInternalClusterFilePathOnlyIfItThePathHasChanged(d.GetShardedClusterMongosProcessNames(name), mongosInternalClusterPath, internalAuthMode) + for i, path := range shardsInternalClusterPath { + d.SetInternalClusterFilePathOnlyIfItThePathHasChanged(d.GetShardedClusterShardProcessNames(name, i), path, internalAuthMode) + } +} + +func setupInternalClusterAuth(d om.Deployment, name string, internalClusterAuthMode string, configInternalClusterPath string, mongosInternalClusterPath string, shardsInternalClusterPath []string) { + d.ConfigureInternalClusterAuthentication(d.GetShardedClusterConfigProcessNames(name), internalClusterAuthMode, configInternalClusterPath) + d.ConfigureInternalClusterAuthentication(d.GetShardedClusterMongosProcessNames(name), internalClusterAuthMode, mongosInternalClusterPath) + + for i, path := range shardsInternalClusterPath { + d.ConfigureInternalClusterAuthentication(d.GetShardedClusterShardProcessNames(name, i), internalClusterAuthMode, path) + } +} + +func getAllProcesses(shards []om.ReplicaSetWithProcesses, configRs om.ReplicaSetWithProcesses, mongosProcesses []om.Process) []om.Process { + allProcesses := make([]om.Process, 0) + for _, shard := range shards { + allProcesses = append(allProcesses, shard.Processes...) + } + allProcesses = append(allProcesses, configRs.Processes...) + allProcesses = append(allProcesses, mongosProcesses...) + return allProcesses +} + +func (r *ReconcileMongoDbShardedCluster) waitForAgentsToRegister(sc *mdbv1.MongoDB, conn om.Connection, opts deploymentOptions, + log *zap.SugaredLogger, mdb *mdbv1.MongoDB) error { + + mongosStatefulSet := construct.DatabaseStatefulSet(*sc, r.getMongosOptions(*sc, opts, log), nil) + if err := agents.WaitForRsAgentsToRegister(mongosStatefulSet, 0, sc.Spec.GetClusterDomain(), conn, log, mdb); err != nil { + return err + } + + configSrvStatefulSet := construct.DatabaseStatefulSet(*sc, r.getConfigServerOptions(*sc, opts, log), nil) + if err := agents.WaitForRsAgentsToRegister(configSrvStatefulSet, 0, sc.Spec.GetClusterDomain(), conn, log, mdb); err != nil { + return err + } + + for i := 0; i < sc.Spec.ShardCount; i++ { + shardStatefulSet := construct.DatabaseStatefulSet(*sc, r.getShardOptions(*sc, i, opts, log), nil) + if err := agents.WaitForRsAgentsToRegister(shardStatefulSet, 0, sc.Spec.GetClusterDomain(), conn, log, mdb); err != nil { + return err + } + } + return nil +} + +func getMaxShardedClusterSizeConfig(specConfig mdbv1.MongodbShardedClusterSizeConfig, statusConfig mdbv1.MongodbShardedClusterSizeConfig) mdbv1.MongodbShardedClusterSizeConfig { + return mdbv1.MongodbShardedClusterSizeConfig{ + MongosCount: util.MaxInt(specConfig.MongosCount, statusConfig.MongosCount), + ConfigServerCount: util.MaxInt(specConfig.ConfigServerCount, statusConfig.ConfigServerCount), + MongodsPerShardCount: util.MaxInt(specConfig.MongodsPerShardCount, statusConfig.MongodsPerShardCount), + ShardCount: util.MaxInt(specConfig.ShardCount, statusConfig.ShardCount), + } +} + +// getAllHostsFromStatus calculates a list of hosts from the "Status" of the Sharded Cluster +func getAllHosts(c *mdbv1.MongoDB, sizeConfig mdbv1.MongodbShardedClusterSizeConfig) []string { + ans := make([]string, 0) + + hosts, _ := dns.GetDNSNames(c.MongosRsName(), c.ServiceName(), c.Namespace, c.Spec.GetClusterDomain(), sizeConfig.MongosCount, nil) + ans = append(ans, hosts...) + + hosts, _ = dns.GetDNSNames(c.ConfigRsName(), c.ConfigSrvServiceName(), c.Namespace, c.Spec.GetClusterDomain(), sizeConfig.ConfigServerCount, nil) + ans = append(ans, hosts...) + + for i := 0; i < sizeConfig.ShardCount; i++ { + hosts, _ = dns.GetDNSNames(c.ShardRsName(i), c.ShardServiceName(), c.Namespace, c.Spec.GetClusterDomain(), sizeConfig.MongodsPerShardCount, nil) + ans = append(ans, hosts...) + } + return ans +} + +func createMongosProcesses(set appsv1.StatefulSet, mdb *mdbv1.MongoDB, certificateFilePath string) []om.Process { + hostnames, names := dns.GetDnsForStatefulSet(set, mdb.Spec.GetClusterDomain(), nil) + processes := make([]om.Process, len(hostnames)) + + for idx, hostname := range hostnames { + processes[idx] = om.NewMongosProcess(names[idx], hostname, mdb.Spec.MongosSpec.GetAdditionalMongodConfig(), mdb.GetSpec(), certificateFilePath) + } + + return processes +} +func createConfigSrvProcesses(set appsv1.StatefulSet, mdb *mdbv1.MongoDB, certificateFilePath string) []om.Process { + return createMongodProcessForShardedCluster(set, mdb.Spec.ConfigSrvSpec.GetAdditionalMongodConfig(), mdb, certificateFilePath) +} +func createShardProcesses(set appsv1.StatefulSet, mdb *mdbv1.MongoDB, certificateFilePath string) []om.Process { + return createMongodProcessForShardedCluster(set, mdb.Spec.ShardSpec.GetAdditionalMongodConfig(), mdb, certificateFilePath) +} +func createMongodProcessForShardedCluster(set appsv1.StatefulSet, additionalMongodConfig *mdbv1.AdditionalMongodConfig, mdb *mdbv1.MongoDB, certificateFilePath string) []om.Process { + hostnames, names := dns.GetDnsForStatefulSet(set, mdb.Spec.GetClusterDomain(), nil) + processes := make([]om.Process, len(hostnames)) + + for idx, hostname := range hostnames { + processes[idx] = om.NewMongodProcess(idx, names[idx], hostname, additionalMongodConfig, &mdb.Spec, certificateFilePath) + } + + return processes +} + +// buildReplicaSetFromProcesses creates the 'ReplicaSetWithProcesses' with specified processes. This is of use only +// for sharded cluster (config server, shards) +func buildReplicaSetFromProcesses(name string, members []om.Process, mdb *mdbv1.MongoDB) om.ReplicaSetWithProcesses { + replicaSet := om.NewReplicaSet(name, mdb.Spec.GetMongoDBVersion()) + rsWithProcesses := om.NewReplicaSetWithProcesses(replicaSet, members, mdb.Spec.GetMemberOptions()) + rsWithProcesses.SetHorizons(mdb.Spec.Connectivity.ReplicaSetHorizons) + return rsWithProcesses +} + +// shardedClusterScaler keeps track of each individual value being scaled on the sharded cluster +// and ensures these values are only incremented or decremented by one +type shardedClusterScaler struct { + DesiredMembers int + CurrentMembers int +} + +func (r shardedClusterScaler) ForcedIndividualScaling() bool { + return false +} + +func (r shardedClusterScaler) DesiredReplicas() int { + return r.DesiredMembers +} + +func (r shardedClusterScaler) CurrentReplicas() int { + return r.CurrentMembers +} + +// getConfigServerOptions returns the Options needed to build the StatefulSet for the config server. +func (r *ReconcileMongoDbShardedCluster) getConfigServerOptions(sc mdbv1.MongoDB, opts deploymentOptions, log *zap.SugaredLogger) func(mdb mdbv1.MongoDB) construct.DatabaseStatefulSetOptions { + certSecretName := sc.GetSecurity().MemberCertificateSecretName(sc.ConfigRsName()) + internalClusterSecretName := sc.GetSecurity().InternalClusterAuthSecretName(sc.ConfigRsName()) + + var vaultConfig vault.VaultConfiguration + var databaseSecretPath string + if r.VaultClient != nil { + vaultConfig = r.VaultClient.VaultConfig + databaseSecretPath = r.VaultClient.DatabaseSecretPath() + } + + return construct.ConfigServerOptions( + Replicas(r.getConfigSrvCountThisReconciliation()), + PodEnvVars(opts.podEnvVars), + CurrentAgentAuthMechanism(opts.currentAgentAuthMode), + CertificateHash(enterprisepem.ReadHashFromSecret(r.SecretClient, sc.Namespace, certSecretName, databaseSecretPath, log)), + InternalClusterHash(enterprisepem.ReadHashFromSecret(r.SecretClient, sc.Namespace, internalClusterSecretName, databaseSecretPath, log)), + PrometheusTLSCertHash(opts.prometheusCertHash), + WithVaultConfig(vaultConfig), + ) +} + +// getMongosOptions returns the Options needed to build the StatefulSet for the mongos. +func (r *ReconcileMongoDbShardedCluster) getMongosOptions(sc mdbv1.MongoDB, opts deploymentOptions, log *zap.SugaredLogger) func(mdb mdbv1.MongoDB) construct.DatabaseStatefulSetOptions { + certSecretName := sc.GetSecurity().MemberCertificateSecretName(sc.MongosRsName()) + internalClusterSecretName := sc.GetSecurity().InternalClusterAuthSecretName(sc.MongosRsName()) + + var vaultConfig vault.VaultConfiguration + if r.VaultClient != nil { + vaultConfig = r.VaultClient.VaultConfig + } + return construct.MongosOptions( + Replicas(r.getMongosCountThisReconciliation()), + PodEnvVars(opts.podEnvVars), + CurrentAgentAuthMechanism(opts.currentAgentAuthMode), + CertificateHash(enterprisepem.ReadHashFromSecret(r.SecretClient, sc.Namespace, certSecretName, vaultConfig.DatabaseSecretPath, log)), + InternalClusterHash(enterprisepem.ReadHashFromSecret(r.SecretClient, sc.Namespace, internalClusterSecretName, vaultConfig.DatabaseSecretPath, log)), + PrometheusTLSCertHash(opts.prometheusCertHash), + WithVaultConfig(vaultConfig), + ) +} + +// getShardOptions returns the Options needed to build the StatefulSet for a given shard. +func (r *ReconcileMongoDbShardedCluster) getShardOptions(sc mdbv1.MongoDB, shardNum int, opts deploymentOptions, log *zap.SugaredLogger) func(mdb mdbv1.MongoDB) construct.DatabaseStatefulSetOptions { + certSecretName := sc.GetSecurity().MemberCertificateSecretName(sc.ShardRsName(shardNum)) + internalClusterSecretName := sc.GetSecurity().InternalClusterAuthSecretName(sc.ShardRsName(shardNum)) + + var vaultConfig vault.VaultConfiguration + var databaseSecretPath string + if r.VaultClient != nil { + vaultConfig = r.VaultClient.VaultConfig + databaseSecretPath = r.VaultClient.DatabaseSecretPath() + } + return construct.ShardOptions(shardNum, + Replicas(r.getMongodsPerShardCountThisReconciliation()), + PodEnvVars(opts.podEnvVars), + CurrentAgentAuthMechanism(opts.currentAgentAuthMode), + CertificateHash(enterprisepem.ReadHashFromSecret(r.SecretClient, sc.Namespace, certSecretName, databaseSecretPath, log)), + InternalClusterHash(enterprisepem.ReadHashFromSecret(r.SecretClient, sc.Namespace, internalClusterSecretName, databaseSecretPath, log)), + PrometheusTLSCertHash(opts.prometheusCertHash), + WithVaultConfig(vaultConfig), + ) +} diff --git a/controllers/operator/mongodbshardedcluster_controller_test.go b/controllers/operator/mongodbshardedcluster_controller_test.go new file mode 100644 index 000000000..5b38fc4ab --- /dev/null +++ b/controllers/operator/mongodbshardedcluster_controller_test.go @@ -0,0 +1,1218 @@ +package operator + +import ( + "context" + "testing" + "time" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/secret" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/statefulset" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/workflow" + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/backup" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/watch" + + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "github.com/10gen/ops-manager-kubernetes/pkg/util/versionutil" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/construct" + + "k8s.io/apimachinery/pkg/api/errors" + + kubernetesClient "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/client" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/configmap" + "k8s.io/apimachinery/pkg/types" + + v1 "github.com/10gen/ops-manager-kubernetes/api/v1" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/controlledfeature" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/mock" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/stretchr/testify/assert" + + "reflect" + + "fmt" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "go.uber.org/zap" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestReconcileCreateShardedCluster(t *testing.T) { + sc := DefaultClusterBuilder().Build() + + reconciler, client := defaultClusterReconciler(sc) + + checkReconcileSuccessful(t, reconciler, sc, client) + + assert.Len(t, client.GetMapForObject(&corev1.Secret{}), 2) + assert.Len(t, client.GetMapForObject(&corev1.Service{}), 3) + assert.Len(t, client.GetMapForObject(&appsv1.StatefulSet{}), 4) + assert.Equal(t, *client.GetSet(kube.ObjectKey(sc.Namespace, sc.ConfigRsName())).Spec.Replicas, int32(sc.Spec.ConfigServerCount)) + assert.Equal(t, *client.GetSet(kube.ObjectKey(sc.Namespace, sc.MongosRsName())).Spec.Replicas, int32(sc.Spec.MongosCount)) + assert.Equal(t, *client.GetSet(kube.ObjectKey(sc.Namespace, sc.ShardRsName(0))).Spec.Replicas, int32(sc.Spec.MongodsPerShardCount)) + assert.Equal(t, *client.GetSet(kube.ObjectKey(sc.Namespace, sc.ShardRsName(1))).Spec.Replicas, int32(sc.Spec.MongodsPerShardCount)) + + connection := om.CurrMockedConnection + connection.CheckDeployment(t, createDeploymentFromShardedCluster(sc), "auth", "tls") + connection.CheckNumberOfUpdateRequests(t, 2) + // we don't remove hosts from monitoring if there is no scale down + connection.CheckOperationsDidntHappen(t, reflect.ValueOf(connection.GetHosts), reflect.ValueOf(connection.RemoveHost)) +} + +func TestReconcileCreateShardedCluster_ScaleDown(t *testing.T) { + // First creation + sc := DefaultClusterBuilder().SetShardCountSpec(4).SetShardCountStatus(4).Build() + reconciler, client := defaultClusterReconciler(sc) + + checkReconcileSuccessful(t, reconciler, sc, client) + + connection := om.CurrMockedConnection + connection.CleanHistory() + + // Scale down then + sc = DefaultClusterBuilder(). + SetShardCountSpec(3). + SetShardCountStatus(4). + Build() + + _ = client.Update(context.TODO(), sc) + + checkReconcileSuccessful(t, reconciler, sc, client) + + // Two deployment modifications are expected + connection.CheckOrderOfOperations(t, reflect.ValueOf(connection.ReadUpdateDeployment), reflect.ValueOf(connection.ReadUpdateDeployment)) + + // todo ideally we need to check the "transitive" deployment that was created on first step, but let's check the + // final version at least + + // the updated deployment should reflect that of a ShardedCluster with one fewer member + scWith3Members := DefaultClusterBuilder().SetShardCountStatus(3).SetShardCountSpec(3).Build() + connection.CheckDeployment(t, createDeploymentFromShardedCluster(scWith3Members), "auth", "tls") + + // No matter how many members we scale down by, we will only have one fewer each reconciliation + assert.Len(t, client.GetMapForObject(&appsv1.StatefulSet{}), 5) +} + +// TestAddDeleteShardedCluster checks that no state is left in OpsManager on removal of the sharded cluster +func TestAddDeleteShardedCluster(t *testing.T) { + // First we need to create a sharded cluster + sc := DefaultClusterBuilder().Build() + + reconciler, client := defaultClusterReconciler(sc) + reconciler.omConnectionFactory = om.NewEmptyMockedOmConnectionWithDelay + + checkReconcileSuccessful(t, reconciler, sc, client) + omConn := om.CurrMockedConnection + omConn.CleanHistory() + + // Now delete it + assert.NoError(t, reconciler.OnDelete(sc, zap.S())) + + // Operator doesn't mutate K8s state, so we don't check its changes, only OM + omConn.CheckResourcesDeleted(t) + + omConn.CheckOrderOfOperations(t, + reflect.ValueOf(omConn.ReadUpdateDeployment), reflect.ValueOf(omConn.ReadAutomationStatus), + reflect.ValueOf(omConn.GetHosts), reflect.ValueOf(omConn.RemoveHost)) + +} + +func getEmptyDeploymentOptions() deploymentOptions { + return deploymentOptions{ + podEnvVars: &env.PodEnvVars{}, + certTLSType: map[string]bool{}, + prometheusCertHash: "", + } +} + +// TestPrepareScaleDownShardedCluster tests the scale down operation for config servers and mongods per shard. It checks +// that all members that will be removed are marked as unvoted +func TestPrepareScaleDownShardedCluster_ConfigMongodsUp(t *testing.T) { + scBeforeScale := DefaultClusterBuilder(). + SetConfigServerCountStatus(3). + SetConfigServerCountSpec(3). + SetMongodsPerShardCountStatus(4). + SetMongodsPerShardCountSpec(4). + Build() + + r, _ := newShardedClusterReconcilerFromResource(*scBeforeScale, om.NewEmptyMockedOmConnection) + + oldDeployment := createDeploymentFromShardedCluster(scBeforeScale) + mockedOmConnection := om.NewMockedOmConnection(oldDeployment) + + scAfterScale := DefaultClusterBuilder(). + SetConfigServerCountStatus(3). + SetConfigServerCountSpec(2). + SetMongodsPerShardCountStatus(4). + SetMongodsPerShardCountSpec(3). + Build() + + r.initCountsForThisReconciliation(*scAfterScale) + + assert.NoError(t, r.prepareScaleDownShardedCluster(mockedOmConnection, scBeforeScale, getEmptyDeploymentOptions(), zap.S())) + + // create the expected deployment from the sharded cluster that has not yet scaled + // expected change of state: rs members are marked unvoted + expectedDeployment := createDeploymentFromShardedCluster(scBeforeScale) + firstConfig := scAfterScale.ConfigRsName() + "-2" + firstShard := scAfterScale.ShardRsName(0) + "-3" + secondShard := scAfterScale.ShardRsName(1) + "-3" + + assert.NoError(t, expectedDeployment.MarkRsMembersUnvoted(scAfterScale.ConfigRsName(), []string{firstConfig})) + assert.NoError(t, expectedDeployment.MarkRsMembersUnvoted(scAfterScale.ShardRsName(0), []string{firstShard})) + assert.NoError(t, expectedDeployment.MarkRsMembersUnvoted(scAfterScale.ShardRsName(1), []string{secondShard})) + + mockedOmConnection.CheckNumberOfUpdateRequests(t, 1) + mockedOmConnection.CheckDeployment(t, expectedDeployment) + // we don't remove hosts from monitoring at this stage + mockedOmConnection.CheckMonitoredHostsRemoved(t, []string{}) +} + +// TestPrepareScaleDownShardedCluster_ShardsUpMongodsDown checks the situation when shards count increases and mongods +// count per shard is decreased - scale down operation is expected to be called only for existing shards +func TestPrepareScaleDownShardedCluster_ShardsUpMongodsDown(t *testing.T) { + scBeforeScale := DefaultClusterBuilder(). + SetShardCountStatus(4). + SetShardCountSpec(4). + SetMongodsPerShardCountStatus(4). + SetMongodsPerShardCountSpec(4). + Build() + + r, _ := newShardedClusterReconcilerFromResource(*scBeforeScale, om.NewEmptyMockedOmConnection) + + oldDeployment := createDeploymentFromShardedCluster(scBeforeScale) + mockedOmConnection := om.NewMockedOmConnection(oldDeployment) + + scAfterScale := DefaultClusterBuilder(). + SetShardCountStatus(4). + SetShardCountSpec(2). + SetMongodsPerShardCountStatus(4). + SetMongodsPerShardCountSpec(3). + Build() + + r.initCountsForThisReconciliation(*scAfterScale) + + assert.NoError(t, r.prepareScaleDownShardedCluster(mockedOmConnection, scBeforeScale, getEmptyDeploymentOptions(), zap.S())) + + // expected change of state: rs members are marked unvoted only for two shards (old state) + expectedDeployment := createDeploymentFromShardedCluster(scBeforeScale) + firstShard := scBeforeScale.ShardRsName(0) + "-3" + secondShard := scBeforeScale.ShardRsName(1) + "-3" + thirdShard := scBeforeScale.ShardRsName(2) + "-3" + fourthShard := scBeforeScale.ShardRsName(3) + "-3" + + assert.NoError(t, expectedDeployment.MarkRsMembersUnvoted(scBeforeScale.ShardRsName(0), []string{firstShard})) + assert.NoError(t, expectedDeployment.MarkRsMembersUnvoted(scBeforeScale.ShardRsName(1), []string{secondShard})) + assert.NoError(t, expectedDeployment.MarkRsMembersUnvoted(scBeforeScale.ShardRsName(2), []string{thirdShard})) + assert.NoError(t, expectedDeployment.MarkRsMembersUnvoted(scBeforeScale.ShardRsName(3), []string{fourthShard})) + + mockedOmConnection.CheckNumberOfUpdateRequests(t, 1) + mockedOmConnection.CheckDeployment(t, expectedDeployment) + //we don't remove hosts from monitoring at this stage + mockedOmConnection.CheckOperationsDidntHappen(t, reflect.ValueOf(mockedOmConnection.RemoveHost)) +} + +func TestConstructConfigSrv(t *testing.T) { + sc := DefaultClusterBuilder().Build() + + assert.NotPanics(t, func() { + construct.DatabaseStatefulSet(*sc, construct.ConfigServerOptions(construct.GetPodEnvOptions()), nil) + }) +} + +// TestPrepareScaleDownShardedCluster_OnlyMongos checks that if only mongos processes are scaled down - then no preliminary +// actions are done +func TestPrepareScaleDownShardedCluster_OnlyMongos(t *testing.T) { + sc := DefaultClusterBuilder().SetMongosCountStatus(4).SetMongosCountSpec(2).Build() + r, _ := newShardedClusterReconcilerFromResource(*sc, om.NewEmptyMockedOmConnection) + + oldDeployment := createDeploymentFromShardedCluster(sc) + mockedOmConnection := om.NewMockedOmConnection(oldDeployment) + + assert.NoError(t, r.prepareScaleDownShardedCluster(mockedOmConnection, sc, getEmptyDeploymentOptions(), zap.S())) + + mockedOmConnection.CheckNumberOfUpdateRequests(t, 0) + mockedOmConnection.CheckDeployment(t, createDeploymentFromShardedCluster(sc)) + mockedOmConnection.CheckOperationsDidntHappen(t, reflect.ValueOf(mockedOmConnection.RemoveHost)) +} + +// TestUpdateOmDeploymentShardedCluster_HostsRemovedFromMonitoring verifies that if scale down operation was performed - +// hosts are removed +func TestUpdateOmDeploymentShardedCluster_HostsRemovedFromMonitoring(t *testing.T) { + sc := DefaultClusterBuilder(). + SetMongosCountStatus(2). + SetMongosCountSpec(2). + SetConfigServerCountStatus(4). + SetConfigServerCountSpec(4). + Build() + + r, _ := newShardedClusterReconcilerFromResource(*sc, om.NewEmptyMockedOmConnection) + + // the deployment we create should have all processes + mockOm := om.NewMockedOmConnection(createDeploymentFromShardedCluster(sc)) + + // we need to create a different sharded cluster that is currently in the process of scaling down + sc = DefaultClusterBuilder(). + SetMongosCountStatus(2). + SetMongosCountSpec(1). + SetConfigServerCountStatus(4). + SetConfigServerCountSpec(3). + Build() + + r.initCountsForThisReconciliation(*sc) + + // updateOmDeploymentShardedCluster checks an element from ac.Auth.DeploymentAuthMechanisms + // so we need to ensure it has a non-nil value. An empty list implies no authentication + _ = mockOm.ReadUpdateAutomationConfig(func(ac *om.AutomationConfig) error { + ac.Auth.DeploymentAuthMechanisms = []string{} + return nil + }, nil) + + assert.Equal(t, workflow.OK(), r.updateOmDeploymentShardedCluster(mockOm, sc, deploymentOptions{podEnvVars: &env.PodEnvVars{ProjectID: "abcd"}}, zap.S())) + + mockOm.CheckOrderOfOperations(t, reflect.ValueOf(mockOm.ReadUpdateDeployment), reflect.ValueOf(mockOm.RemoveHost)) + + // expected change of state: no unvoting - just monitoring deleted + firstConfig := sc.ConfigRsName() + "-3" + firstMongos := sc.MongosRsName() + "-1" + + mockOm.CheckMonitoredHostsRemoved(t, []string{ + firstConfig + ".slaney-cs.mongodb.svc.cluster.local", + firstMongos + ".slaney-svc.mongodb.svc.cluster.local", + }) +} + +// CLOUDP-32765: checks that pod anti affinity rule spreads mongods inside one shard, not inside all shards +func TestPodAntiaffinity_MongodsInsideShardAreSpread(t *testing.T) { + sc := DefaultClusterBuilder().Build() + + firstShardSet := construct.DatabaseStatefulSet(*sc, construct.ShardOptions(0, construct.GetPodEnvOptions()), nil) + secondShardSet := construct.DatabaseStatefulSet(*sc, construct.ShardOptions(1, construct.GetPodEnvOptions()), nil) + + assert.Equal(t, sc.ShardRsName(0), firstShardSet.Spec.Selector.MatchLabels[construct.PodAntiAffinityLabelKey]) + assert.Equal(t, sc.ShardRsName(1), secondShardSet.Spec.Selector.MatchLabels[construct.PodAntiAffinityLabelKey]) + + firstShartPodAffinityTerm := firstShardSet.Spec.Template.Spec.Affinity.PodAntiAffinity.PreferredDuringSchedulingIgnoredDuringExecution[0].PodAffinityTerm + assert.Equal(t, firstShartPodAffinityTerm.LabelSelector.MatchLabels[construct.PodAntiAffinityLabelKey], sc.ShardRsName(0)) + + secondShartPodAffinityTerm := secondShardSet.Spec.Template.Spec.Affinity.PodAntiAffinity.PreferredDuringSchedulingIgnoredDuringExecution[0].PodAffinityTerm + assert.Equal(t, secondShartPodAffinityTerm.LabelSelector.MatchLabels[construct.PodAntiAffinityLabelKey], sc.ShardRsName(1)) +} + +func TestShardedCluster_WithTLSEnabled_AndX509Enabled_Succeeds(t *testing.T) { + sc := DefaultClusterBuilder(). + EnableTLS(). + EnableX509(). + SetTLSCA("custom-ca"). + Build() + + reconciler, client := defaultClusterReconciler(sc) + addKubernetesTlsResources(client, sc) + + actualResult, err := reconciler.Reconcile(context.TODO(), requestFromObject(sc)) + expectedResult := reconcile.Result{} + + assert.Equal(t, expectedResult, actualResult) + assert.Nil(t, err) +} + +func TestShardedCluster_NeedToPublishState(t *testing.T) { + sc := DefaultClusterBuilder(). + EnableTLS(). + SetTLSCA("custom-ca"). + Build() + + // perform successful reconciliation to populate all the stateful sets in the mocked client + reconciler, client := defaultClusterReconciler(sc) + addKubernetesTlsResources(client, sc) + actualResult, err := reconciler.Reconcile(context.TODO(), requestFromObject(sc)) + expectedResult := reconcile.Result{} + + assert.Equal(t, expectedResult, actualResult) + assert.Nil(t, err) + + allConfigs := reconciler.getAllConfigs(*sc, getEmptyDeploymentOptions(), zap.S()) + + assert.False(t, anyStatefulSetNeedsToPublishState(*sc, client, allConfigs, zap.S())) + + // attempting to set tls to false + sc.Spec.Security.TLSConfig.Enabled = false + + err = client.Update(context.TODO(), sc) + assert.NoError(t, err) + + // Ops Manager state needs to be published first as we want to reach goal state before unmounting certificates + allConfigs = reconciler.getAllConfigs(*sc, getEmptyDeploymentOptions(), zap.S()) + assert.True(t, anyStatefulSetNeedsToPublishState(*sc, client, allConfigs, zap.S())) +} + +func TestShardedCustomPodSpecTemplate(t *testing.T) { + shardPodSpec := corev1.PodSpec{ + NodeName: "some-node-name", + Hostname: "some-host-name", + Containers: []corev1.Container{{ + Name: "my-custom-container-sc", + Image: "my-custom-image", + VolumeMounts: []corev1.VolumeMount{{ + Name: "my-volume-mount", + }}, + }}, + RestartPolicy: corev1.RestartPolicyAlways, + } + + mongosPodSpec := corev1.PodSpec{ + NodeName: "some-node-name-mongos", + Hostname: "some-host-name-mongos", + Containers: []corev1.Container{{ + Name: "my-custom-container-mongos", + Image: "my-custom-image", + VolumeMounts: []corev1.VolumeMount{{ + Name: "my-volume-mount", + }}, + }}, + RestartPolicy: corev1.RestartPolicyNever, + } + + configSrvPodSpec := corev1.PodSpec{ + NodeName: "some-node-name-config", + Hostname: "some-host-name-config", + Containers: []corev1.Container{{ + Name: "my-custom-container-config", + Image: "my-custom-image", + VolumeMounts: []corev1.VolumeMount{{ + Name: "my-volume-mount", + }}, + }}, + RestartPolicy: corev1.RestartPolicyOnFailure, + } + + sc := DefaultClusterBuilder().SetName("pod-spec-sc").EnableTLS().SetTLSCA("custom-ca"). + SetShardPodSpec(corev1.PodTemplateSpec{ + Spec: shardPodSpec, + }).SetMongosPodSpecTemplate(corev1.PodTemplateSpec{ + Spec: mongosPodSpec, + }).SetPodConfigSvrSpecTemplate(corev1.PodTemplateSpec{ + Spec: configSrvPodSpec, + }).Build() + + reconciler, client := defaultClusterReconciler(sc) + + addKubernetesTlsResources(client, sc) + + checkReconcileSuccessful(t, reconciler, sc, client) + + // read the stateful sets that were created by the operator + statefulSetSc0, err := client.GetStatefulSet(kube.ObjectKey(mock.TestNamespace, "pod-spec-sc-0")) + assert.NoError(t, err) + statefulSetSc1, err := client.GetStatefulSet(kube.ObjectKey(mock.TestNamespace, "pod-spec-sc-1")) + assert.NoError(t, err) + statefulSetScConfig, err := client.GetStatefulSet(kube.ObjectKey(mock.TestNamespace, "pod-spec-sc-config")) + assert.NoError(t, err) + statefulSetMongoS, err := client.GetStatefulSet(kube.ObjectKey(mock.TestNamespace, "pod-spec-sc-mongos")) + assert.NoError(t, err) + + // assert Pod Spec for Sharded cluster + assertPodSpecSts(t, &statefulSetSc0, shardPodSpec.NodeName, shardPodSpec.Hostname, shardPodSpec.RestartPolicy) + assertPodSpecSts(t, &statefulSetSc1, shardPodSpec.NodeName, shardPodSpec.Hostname, shardPodSpec.RestartPolicy) + + // assert Pod Spec for Mongos + assertPodSpecSts(t, &statefulSetMongoS, mongosPodSpec.NodeName, mongosPodSpec.Hostname, mongosPodSpec.RestartPolicy) + + // assert Pod Spec for ConfigServer + assertPodSpecSts(t, &statefulSetScConfig, configSrvPodSpec.NodeName, configSrvPodSpec.Hostname, configSrvPodSpec.RestartPolicy) + + podSpecTemplateSc0 := statefulSetSc0.Spec.Template.Spec + assert.Len(t, podSpecTemplateSc0.Containers, 2, "Should have 2 containers now") + assert.Equal(t, util.DatabaseContainerName, podSpecTemplateSc0.Containers[0].Name, "Database container should always be first") + assert.Equal(t, "my-custom-container-sc", podSpecTemplateSc0.Containers[1].Name, "Custom container should be second") + + podSpecTemplateSc1 := statefulSetSc1.Spec.Template.Spec + assert.Len(t, podSpecTemplateSc1.Containers, 2, "Should have 2 containers now") + assert.Equal(t, util.DatabaseContainerName, podSpecTemplateSc1.Containers[0].Name, "Database container should always be first") + assert.Equal(t, "my-custom-container-sc", podSpecTemplateSc1.Containers[1].Name, "Custom container should be second") + + podSpecTemplateMongoS := statefulSetMongoS.Spec.Template.Spec + assert.Len(t, podSpecTemplateMongoS.Containers, 2, "Should have 2 containers now") + assert.Equal(t, util.DatabaseContainerName, podSpecTemplateMongoS.Containers[0].Name, "Database container should always be first") + assert.Equal(t, "my-custom-container-mongos", podSpecTemplateMongoS.Containers[1].Name, "Custom container should be second") + + podSpecTemplateScConfig := statefulSetScConfig.Spec.Template.Spec + assert.Len(t, podSpecTemplateScConfig.Containers, 2, "Should have 2 containers now") + assert.Equal(t, util.DatabaseContainerName, podSpecTemplateScConfig.Containers[0].Name, "Database container should always be first") + assert.Equal(t, "my-custom-container-config", podSpecTemplateScConfig.Containers[1].Name, "Custom container should be second") +} + +func TestFeatureControlsNoAuth(t *testing.T) { + sc := DefaultClusterBuilder().RemoveAuth().Build() + reconciler, client := defaultClusterReconciler(sc) + reconciler.omConnectionFactory = func(context *om.OMContext) om.Connection { + context.Version = versionutil.OpsManagerVersion{ + VersionString: "5.0.0", + } + conn := om.NewEmptyMockedOmConnection(context) + return conn + } + + checkReconcileSuccessful(t, reconciler, sc, client) + + mockedConn := om.CurrMockedConnection + cf, _ := mockedConn.GetControlledFeature() + + assert.Len(t, cf.Policies, 2) + + assert.Equal(t, cf.ManagementSystem.Version, util.OperatorVersion) + assert.Equal(t, cf.ManagementSystem.Name, util.OperatorName) + assert.Equal(t, cf.Policies[0].PolicyType, controlledfeature.ExternallyManaged) + assert.Equal(t, cf.Policies[1].PolicyType, controlledfeature.DisableMongodVersion) + assert.Len(t, cf.Policies[0].DisabledParams, 0) + +} + +func TestScalingShardedCluster_ScalesOneMemberAtATime_WhenScalingUp(t *testing.T) { + sc := DefaultClusterBuilder(). + SetMongodsPerShardCountSpec(3). + SetMongodsPerShardCountStatus(3). + SetConfigServerCountSpec(1). + SetConfigServerCountStatus(1). + SetMongosCountSpec(1). + SetMongosCountStatus(0). + SetShardCountSpec(1). + SetShardCountStatus(0). + Build() + + reconciler, client := defaultClusterReconciler(sc) + // perform initial reconciliation so we are not creating a new resource + checkReconcileSuccessful(t, reconciler, sc, client) + + // Scale up the Sharded Cluster + sc.Spec.MongodsPerShardCount = 6 + sc.Spec.MongosCount = 3 + sc.Spec.ShardCount = 2 + sc.Spec.ConfigServerCount = 2 + + err := client.Update(context.TODO(), sc) + assert.NoError(t, err) + + var deployment om.Deployment + performReconciliation := func(shouldRetry bool) { + res, err := reconciler.Reconcile(context.TODO(), requestFromObject(sc)) + assert.NoError(t, err) + if shouldRetry { + assert.Equal(t, time.Duration(10000000000), res.RequeueAfter) + } else { + assert.Equal(t, time.Duration(0), res.RequeueAfter) + } + err = client.Get(context.TODO(), sc.ObjectKey(), sc) + assert.NoError(t, err) + + deployment, err = om.CurrMockedConnection.ReadDeployment() + assert.NoError(t, err) + } + + getShard := func(i int) appsv1.StatefulSet { + sts := appsv1.StatefulSet{} + err := client.Get(context.TODO(), types.NamespacedName{Name: sc.ShardRsName(i), Namespace: sc.Namespace}, &sts) + assert.NoError(t, err) + return sts + } + + t.Run("1st reconciliation", func(t *testing.T) { + performReconciliation(true) + + assert.Equal(t, 2, sc.Status.MongosCount) + assert.Equal(t, 2, sc.Status.ConfigServerCount) + assert.Equal(t, int32(4), *getShard(0).Spec.Replicas) + assert.Equal(t, int32(4), *getShard(1).Spec.Replicas) + assert.Len(t, deployment.GetAllProcessNames(), 12) + }) + + t.Run("2nd reconciliation", func(t *testing.T) { + performReconciliation(true) + assert.Equal(t, 3, sc.Status.MongosCount) + assert.Equal(t, 2, sc.Status.ConfigServerCount) + assert.Equal(t, int32(5), *getShard(0).Spec.Replicas) + assert.Equal(t, int32(5), *getShard(1).Spec.Replicas) + assert.Len(t, deployment.GetAllProcessNames(), 15) + }) + + t.Run("3rd reconciliation", func(t *testing.T) { + performReconciliation(false) + assert.Equal(t, 3, sc.Status.MongosCount) + assert.Equal(t, 2, sc.Status.ConfigServerCount) + assert.Equal(t, int32(6), *getShard(0).Spec.Replicas) + assert.Equal(t, int32(6), *getShard(1).Spec.Replicas) + assert.Len(t, deployment.GetAllProcessNames(), 17) + }) +} + +func TestScalingShardedCluster_ScalesOneMemberAtATime_WhenScalingDown(t *testing.T) { + sc := DefaultClusterBuilder(). + SetMongodsPerShardCountSpec(6). + SetMongodsPerShardCountStatus(6). + SetConfigServerCountSpec(3). + SetConfigServerCountStatus(3). + SetMongosCountSpec(3). + SetMongosCountStatus(3). + SetShardCountSpec(2). + SetShardCountStatus(2). + Build() + + reconciler, client := defaultClusterReconciler(sc) + // perform initial reconciliation so we are not creating a new resource + checkReconcileSuccessful(t, reconciler, sc, client) + + err := client.Get(context.TODO(), sc.ObjectKey(), sc) + assert.NoError(t, err) + + assert.Equal(t, 2, sc.Status.ShardCount) + + // Scale up the Sharded Cluster + sc.Spec.MongodsPerShardCount = 3 + sc.Spec.MongosCount = 1 + sc.Spec.ShardCount = 1 + sc.Spec.ConfigServerCount = 1 + + err = client.Update(context.TODO(), sc) + assert.NoError(t, err) + + performReconciliation := func(shouldRetry bool) { + res, err := reconciler.Reconcile(context.TODO(), requestFromObject(sc)) + assert.NoError(t, err) + if shouldRetry { + assert.Equal(t, time.Duration(10000000000), res.RequeueAfter) + } else { + assert.Equal(t, time.Duration(0), res.RequeueAfter) + } + err = client.Get(context.TODO(), sc.ObjectKey(), sc) + assert.NoError(t, err) + } + + getShard := func(i int) *appsv1.StatefulSet { + sts := appsv1.StatefulSet{} + err := client.Get(context.TODO(), types.NamespacedName{Name: sc.ShardRsName(i), Namespace: sc.Namespace}, &sts) + if errors.IsNotFound(err) { + return nil + } + return &sts + } + + t.Run("1st reconciliation", func(t *testing.T) { + performReconciliation(true) + assert.Equal(t, 2, sc.Status.ShardCount) + assert.Equal(t, 2, sc.Status.MongosCount) + assert.Equal(t, 2, sc.Status.ConfigServerCount) + assert.Equal(t, int32(5), *getShard(0).Spec.Replicas) + assert.NotNil(t, getShard(1), "Shard should be removed until the scaling operation is complete") + }) + t.Run("2nd reconciliation", func(t *testing.T) { + performReconciliation(true) + assert.Equal(t, 2, sc.Status.ShardCount) + assert.Equal(t, 1, sc.Status.MongosCount) + assert.Equal(t, 1, sc.Status.ConfigServerCount) + assert.Equal(t, int32(4), *getShard(0).Spec.Replicas) + assert.NotNil(t, getShard(1), "Shard should be removed until the scaling operation is complete") + }) + t.Run("Final reconciliation", func(t *testing.T) { + performReconciliation(false) + assert.Equal(t, 1, sc.Status.ShardCount, "Upon finishing reconciliation, the original shard count should be set to the current value") + assert.Equal(t, 1, sc.Status.MongosCount) + assert.Equal(t, 1, sc.Status.ConfigServerCount) + assert.Equal(t, int32(3), *getShard(0).Spec.Replicas) + assert.Nil(t, getShard(1), "Shard should be removed as we have reached have finished scaling") + }) +} + +func TestFeatureControlsAuthEnabled(t *testing.T) { + sc := DefaultClusterBuilder().Build() + reconciler, client := defaultClusterReconciler(sc) + reconciler.omConnectionFactory = func(context *om.OMContext) om.Connection { + context.Version = versionutil.OpsManagerVersion{ + VersionString: "5.0.0", + } + conn := om.NewEmptyMockedOmConnection(context) + return conn + } + + checkReconcileSuccessful(t, reconciler, sc, client) + + mockedConn := om.CurrMockedConnection + cf, _ := mockedConn.GetControlledFeature() + + assert.Len(t, cf.Policies, 3) + + assert.Equal(t, cf.ManagementSystem.Version, util.OperatorVersion) + assert.Equal(t, cf.ManagementSystem.Name, util.OperatorName) + + var policies []controlledfeature.PolicyType + for _, p := range cf.Policies { + policies = append(policies, p.PolicyType) + } + + assert.Contains(t, policies, controlledfeature.ExternallyManaged) + assert.Contains(t, policies, controlledfeature.DisableAuthenticationMechanisms) + assert.Contains(t, policies, controlledfeature.DisableMongodVersion) +} + +func TestShardedClusterPortsAreConfigurable_WithAdditionalMongoConfig(t *testing.T) { + configSrvConfig := mdbv1.NewAdditionalMongodConfig("net.port", 30000) + mongosConfig := mdbv1.NewAdditionalMongodConfig("net.port", 30001) + shardConfig := mdbv1.NewAdditionalMongodConfig("net.port", 30002) + + sc := mdbv1.NewClusterBuilder(). + SetNamespace(mock.TestNamespace). + SetConnectionSpec(testConnectionSpec()). + SetConfigSrvAdditionalConfig(configSrvConfig). + SetMongosAdditionalConfig(mongosConfig). + SetShardAdditionalConfig(shardConfig). + Build() + + reconciler, client := defaultClusterReconciler(sc) + + checkReconcileSuccessful(t, reconciler, sc, client) + + t.Run("Config Server Port is configured", func(t *testing.T) { + configSrvSvc, err := client.GetService(kube.ObjectKey(sc.Namespace, sc.ConfigSrvServiceName())) + assert.NoError(t, err) + assert.Equal(t, int32(30000), configSrvSvc.Spec.Ports[0].Port) + }) + + t.Run("Mongos Port is configured", func(t *testing.T) { + mongosSvc, err := client.GetService(kube.ObjectKey(sc.Namespace, sc.ServiceName())) + assert.NoError(t, err) + assert.Equal(t, int32(30001), mongosSvc.Spec.Ports[0].Port) + }) + + t.Run("Shard Port is configured", func(t *testing.T) { + shardSvc, err := client.GetService(kube.ObjectKey(sc.Namespace, sc.ShardServiceName())) + assert.NoError(t, err) + assert.Equal(t, int32(30002), shardSvc.Spec.Ports[0].Port) + }) +} + +// TestShardedCluster_ConfigMapAndSecretWatched verifies that config map and secret are added to the internal +// map that allows to watch them for changes +func TestShardedCluster_ConfigMapAndSecretWatched(t *testing.T) { + sc := DefaultClusterBuilder().Build() + + reconciler, client := defaultClusterReconciler(sc) + + checkReconcileSuccessful(t, reconciler, sc, client) + + expected := map[watch.Object][]types.NamespacedName{ + {ResourceType: watch.ConfigMap, Resource: kube.ObjectKey(mock.TestNamespace, mock.TestProjectConfigMapName)}: {kube.ObjectKey(mock.TestNamespace, sc.Name)}, + {ResourceType: watch.Secret, Resource: kube.ObjectKey(mock.TestNamespace, sc.Spec.Credentials)}: {kube.ObjectKey(mock.TestNamespace, sc.Name)}, + } + + assert.Equal(t, reconciler.WatchedResources, expected) +} + +// TestShardedClusterTLSResourcesWatched verifies that TLS config map and secret are added to the internal +// map that allows to watch them for changes +func TestShardedClusterTLSAndInternalAuthResourcesWatched(t *testing.T) { + sc := DefaultClusterBuilder().SetShardCountSpec(1).EnableTLS().SetTLSCA("custom-ca").Build() + sc.Spec.Security.Authentication.InternalCluster = "x509" + reconciler, client := defaultClusterReconciler(sc) + + addKubernetesTlsResources(client, sc) + checkReconcileSuccessful(t, reconciler, sc, client) + + expectedWatchedResources := []watch.Object{ + getWatch(sc.Namespace, sc.Name+"-config-cert", watch.Secret), + getWatch(sc.Namespace, sc.Name+"-config-clusterfile", watch.Secret), + getWatch(sc.Namespace, sc.Name+"-mongos-cert", watch.Secret), + getWatch(sc.Namespace, sc.Name+"-mongos-clusterfile", watch.Secret), + getWatch(sc.Namespace, sc.Name+"-0-cert", watch.Secret), + getWatch(sc.Namespace, sc.Name+"-0-clusterfile", watch.Secret), + getWatch(sc.Namespace, "custom-ca", watch.ConfigMap), + getWatch(sc.Namespace, "my-credentials", watch.Secret), + getWatch(sc.Namespace, "my-project", watch.ConfigMap), + } + + var actual []watch.Object + for obj := range reconciler.WatchedResources { + actual = append(actual, obj) + } + + assert.ElementsMatch(t, expectedWatchedResources, actual) + + sc.Spec.Security.TLSConfig.Enabled = false + sc.Spec.Security.Authentication.InternalCluster = "" + err := client.Update(context.TODO(), sc) + assert.NoError(t, err) + + res, err := reconciler.Reconcile(context.TODO(), requestFromObject(sc)) + assert.Equal(t, reconcile.Result{}, res) + assert.NoError(t, err) + assert.Len(t, reconciler.WatchedResources, 2) + +} + +func TestBackupConfiguration_ShardedCluster(t *testing.T) { + sc := mdbv1.NewClusterBuilder(). + SetNamespace(mock.TestNamespace). + SetConnectionSpec(testConnectionSpec()). + SetBackup(mdbv1.Backup{ + Mode: "enabled", + }). + Build() + + reconciler, client := defaultClusterReconciler(sc) + + // configure backup for this project in Ops Manager in the mocked connection + om.CurrMockedConnection = om.NewMockedOmConnection(om.NewDeployment()) + + // 4 because configserver + num shards + 1 for entity to represent the sharded cluster iteself + clusterIds := []string{"1", "2", "3", "4"} + typeNames := []string{"SHARDED_REPLICA_SET", "REPLICA_SET", "REPLICA_SET", "CONFIG_SERVER_REPLICA_SET"} + for i, clusterId := range clusterIds { + om.CurrMockedConnection.UpdateBackupConfig(&backup.Config{ + ClusterId: clusterId, + Status: backup.Inactive, + }) + + om.CurrMockedConnection.BackupHostClusters[clusterId] = &backup.HostCluster{ + ClusterName: sc.Name, + ShardName: "ShardedCluster", + TypeName: typeNames[i], + } + } + + assertAllOtherBackupConfigsRemainUntouched := func(t *testing.T) { + for _, configId := range []string{"2", "3", "4"} { + config, err := om.CurrMockedConnection.ReadBackupConfig(configId) + assert.NoError(t, err) + // backup status should remain INACTIVE for all non "SHARDED_REPLICA_SET" configs. + assert.Equal(t, backup.Inactive, config.Status) + } + } + + t.Run("Backup can be started", func(t *testing.T) { + checkReconcileSuccessful(t, reconciler, sc, client) + + config, err := om.CurrMockedConnection.ReadBackupConfig("1") + assert.NoError(t, err) + assert.Equal(t, backup.Started, config.Status) + assert.Equal(t, "1", config.ClusterId) + assert.Equal(t, "PRIMARY", config.SyncSource) + assertAllOtherBackupConfigsRemainUntouched(t) + }) + + t.Run("Backup snapshot schedule tests", backupSnapshotScheduleTests(sc, client, reconciler, "1")) + + t.Run("Backup can be stopped", func(t *testing.T) { + sc.Spec.Backup.Mode = "disabled" + err := client.Update(context.TODO(), sc) + assert.NoError(t, err) + + checkReconcileSuccessful(t, reconciler, sc, client) + + config, err := om.CurrMockedConnection.ReadBackupConfig("1") + assert.NoError(t, err) + assert.Equal(t, backup.Stopped, config.Status) + assert.Equal(t, "1", config.ClusterId) + assert.Equal(t, "PRIMARY", config.SyncSource) + assertAllOtherBackupConfigsRemainUntouched(t) + }) + + t.Run("Backup can be terminated", func(t *testing.T) { + sc.Spec.Backup.Mode = "terminated" + err := client.Update(context.TODO(), sc) + assert.NoError(t, err) + + checkReconcileSuccessful(t, reconciler, sc, client) + + config, err := om.CurrMockedConnection.ReadBackupConfig("1") + assert.NoError(t, err) + assert.Equal(t, backup.Terminating, config.Status) + assert.Equal(t, "1", config.ClusterId) + assert.Equal(t, "PRIMARY", config.SyncSource) + assertAllOtherBackupConfigsRemainUntouched(t) + }) + +} + +// createShardedClusterTLSSecretsFromCustomCerts creates and populates all the required +// secrets required to enabled TLS with custom certs for all sharded cluster components. +func createShardedClusterTLSSecretsFromCustomCerts(sc *mdbv1.MongoDB, prefix string, client kubernetesClient.Client) { + mongosSecret := secret.Builder(). + SetName(fmt.Sprintf("%s-%s-cert", prefix, sc.MongosRsName())). + SetNamespace(sc.Namespace).SetDataType(corev1.SecretTypeTLS). + Build() + + mongosSecret.Data["tls.crt"], mongosSecret.Data["tls.key"] = createMockCertAndKeyBytes() + + err := client.CreateSecret(mongosSecret) + if err != nil { + panic(err) + } + + configSrvSecret := secret.Builder(). + SetName(fmt.Sprintf("%s-%s-cert", prefix, sc.ConfigRsName())). + SetNamespace(sc.Namespace).SetDataType(corev1.SecretTypeTLS). + Build() + + configSrvSecret.Data["tls.crt"], configSrvSecret.Data["tls.key"] = createMockCertAndKeyBytes() + + err = client.CreateSecret(configSrvSecret) + if err != nil { + panic(err) + } + + for i := 0; i < sc.Spec.ShardCount; i++ { + shardSecret := secret.Builder(). + SetName(fmt.Sprintf("%s-%s-cert", prefix, sc.ShardRsName(i))). + SetNamespace(sc.Namespace).SetDataType(corev1.SecretTypeTLS). + Build() + + shardSecret.Data["tls.crt"], shardSecret.Data["tls.key"] = createMockCertAndKeyBytes() + + err = client.CreateSecret(shardSecret) + if err != nil { + panic(err) + } + } +} + +func TestTlsConfigPrefix_ForShardedCluster(t *testing.T) { + sc := DefaultClusterBuilder(). + SetTLSConfig(mdbv1.TLSConfig{ + Enabled: false, + }). + Build() + + reconciler, client := defaultClusterReconciler(sc) + + createShardedClusterTLSSecretsFromCustomCerts(sc, "my-prefix", client) + + checkReconcileSuccessful(t, reconciler, sc, client) +} + +func TestShardSpecificPodSpec(t *testing.T) { + shardPodSpec := corev1.PodSpec{ + NodeName: "some-node-name", + Hostname: "some-host-name", + Containers: []corev1.Container{{ + Name: "my-custom-container-sc", + Image: "my-custom-image", + VolumeMounts: []corev1.VolumeMount{{ + Name: "my-volume-mount", + }}, + }}, + RestartPolicy: corev1.RestartPolicyAlways, + } + + shard0PodSpec := corev1.PodSpec{ + NodeName: "shard0-node-name", + Containers: []corev1.Container{{ + Name: "shard0-container", + Image: "shard0-custom-image", + VolumeMounts: []corev1.VolumeMount{{ + Name: "shard0-volume-mount", + }}, + }}, + RestartPolicy: corev1.RestartPolicyAlways, + } + + sc := DefaultClusterBuilder().SetName("shard-specific-pod-spec").EnableTLS().SetTLSCA("custom-ca"). + SetShardPodSpec(corev1.PodTemplateSpec{ + Spec: shardPodSpec, + }).SetShardSpecificPodSpecTemplate([]corev1.PodTemplateSpec{ + { + Spec: shard0PodSpec, + }, + }).Build() + + reconciler, client := defaultClusterReconciler(sc) + addKubernetesTlsResources(client, sc) + checkReconcileSuccessful(t, reconciler, sc, client) + + // read the statefulsets from the cluster + statefulSetSc0, err := client.GetStatefulSet(kube.ObjectKey(mock.TestNamespace, "shard-specific-pod-spec-0")) + assert.NoError(t, err) + statefulSetSc1, err := client.GetStatefulSet(kube.ObjectKey(mock.TestNamespace, "shard-specific-pod-spec-1")) + assert.NoError(t, err) + + // shard0 should have the override + assertPodSpecSts(t, &statefulSetSc0, shard0PodSpec.NodeName, shard0PodSpec.Hostname, shard0PodSpec.RestartPolicy) + + // shard1 should have the common one + assertPodSpecSts(t, &statefulSetSc1, shardPodSpec.NodeName, shardPodSpec.Hostname, shardPodSpec.RestartPolicy) +} + +func assertPodSpecSts(t *testing.T, sts *appsv1.StatefulSet, nodeName, hostName string, restartPolicy corev1.RestartPolicy) { + + podSpecTemplate := sts.Spec.Template.Spec + // ensure values were passed to the stateful set + assert.Equal(t, nodeName, podSpecTemplate.NodeName) + assert.Equal(t, hostName, podSpecTemplate.Hostname) + assert.Equal(t, restartPolicy, podSpecTemplate.RestartPolicy) + + assert.Equal(t, util.DatabaseContainerName, podSpecTemplate.Containers[0].Name, "Database container should always be first") + assert.True(t, statefulset.VolumeMountWithNameExists(podSpecTemplate.Containers[0].VolumeMounts, construct.PvcNameDatabaseScripts)) +} + +func createDeploymentFromShardedCluster(updatable v1.CustomResourceReadWriter) om.Deployment { + sh := updatable.(*mdbv1.MongoDB) + + mongosSts := construct.DatabaseStatefulSet(*sh, construct.MongosOptions(Replicas(sh.Spec.MongosCount), construct.GetPodEnvOptions()), nil) + mongosProcesses := createMongosProcesses(mongosSts, sh, util.PEMKeyFilePathInContainer) + configSvrSts := construct.DatabaseStatefulSet(*sh, construct.ConfigServerOptions(Replicas(sh.Spec.ConfigServerCount), construct.GetPodEnvOptions()), nil) + + configRs := buildReplicaSetFromProcesses(configSvrSts.Name, createConfigSrvProcesses(configSvrSts, sh, ""), sh) + shards := make([]om.ReplicaSetWithProcesses, sh.Spec.ShardCount) + for i := 0; i < sh.Spec.ShardCount; i++ { + shardSts := construct.DatabaseStatefulSet(*sh, construct.ShardOptions(i, Replicas(sh.Spec.MongodsPerShardCount), construct.GetPodEnvOptions()), nil) + shards[i] = buildReplicaSetFromProcesses(shardSts.Name, createShardProcesses(shardSts, sh, ""), sh) + } + + d := om.NewDeployment() + d.MergeShardedCluster(om.DeploymentShardedClusterMergeOptions{ + Name: sh.Name, + MongosProcesses: mongosProcesses, + ConfigServerRs: configRs, + Shards: shards, + Finalizing: false, + }) + d.AddMonitoringAndBackup(zap.S(), sh.Spec.GetSecurity().IsTLSEnabled(), util.CAFilePathInContainer) + return d +} + +// defaultClusterReconciler is the sharded cluster reconciler used in unit test. It "adds" necessary +// additional K8s objects (connection config map and secrets) necessary for reconciliation +func defaultClusterReconciler(sc *mdbv1.MongoDB) (*ReconcileMongoDbShardedCluster, *mock.MockedClient) { + r, manager := newShardedClusterReconcilerFromResource(*sc, om.NewEmptyMockedOmConnection) + manager.Client.AddDefaultMdbConfigResources() + return r, manager.Client +} + +func newShardedClusterReconcilerFromResource(sc mdbv1.MongoDB, omFunc om.ConnectionFactory) (*ReconcileMongoDbShardedCluster, *mock.MockedManager) { + mgr := mock.NewManager(&sc) + r := &ReconcileMongoDbShardedCluster{ + ReconcileCommonController: newReconcileCommonController(mgr), + omConnectionFactory: omFunc, + } + r.initCountsForThisReconciliation(sc) + return r, mgr +} + +type ClusterBuilder struct { + *mdbv1.MongoDB +} + +func DefaultClusterBuilder() *ClusterBuilder { + sizeConfig := mdbv1.MongodbShardedClusterSizeConfig{ + ShardCount: 2, + MongodsPerShardCount: 3, + ConfigServerCount: 3, + MongosCount: 4, + } + + status := mdbv1.MongoDbStatus{ + MongodbShardedClusterSizeConfig: sizeConfig, + } + + spec := mdbv1.MongoDbSpec{ + DbCommonSpec: mdbv1.DbCommonSpec{ + Persistent: util.BooleanRef(false), + ConnectionSpec: mdbv1.ConnectionSpec{ + SharedConnectionSpec: mdbv1.SharedConnectionSpec{ + OpsManagerConfig: &mdbv1.PrivateCloudConfig{ + ConfigMapRef: mdbv1.ConfigMapRef{ + Name: mock.TestProjectConfigMapName, + }, + }, + }, + Credentials: mock.TestCredentialsSecretName, + }, + Version: "3.6.4", + ResourceType: mdbv1.ShardedCluster, + + Security: &mdbv1.Security{ + TLSConfig: &mdbv1.TLSConfig{}, + Authentication: &mdbv1.Authentication{ + Modes: []string{}, + }, + }, + }, + MongodbShardedClusterSizeConfig: sizeConfig, + ShardedClusterSpec: mdbv1.ShardedClusterSpec{ + ConfigSrvSpec: &mdbv1.ShardedClusterComponentSpec{}, + MongosSpec: &mdbv1.ShardedClusterComponentSpec{}, + ShardSpec: &mdbv1.ShardedClusterComponentSpec{}, + }, + } + + resource := &mdbv1.MongoDB{ + ObjectMeta: metav1.ObjectMeta{Name: "slaney", Namespace: mock.TestNamespace}, + Status: status, + Spec: spec, + } + + return &ClusterBuilder{resource} +} + +func (b *ClusterBuilder) SetName(name string) *ClusterBuilder { + b.Name = name + return b +} +func (b *ClusterBuilder) SetShardCountSpec(count int) *ClusterBuilder { + b.Spec.ShardCount = count + return b +} +func (b *ClusterBuilder) SetMongodsPerShardCountSpec(count int) *ClusterBuilder { + b.Spec.MongodsPerShardCount = count + return b +} +func (b *ClusterBuilder) SetConfigServerCountSpec(count int) *ClusterBuilder { + b.Spec.ConfigServerCount = count + return b +} +func (b *ClusterBuilder) SetMongosCountSpec(count int) *ClusterBuilder { + b.Spec.MongosCount = count + return b +} +func (b *ClusterBuilder) SetShardCountStatus(count int) *ClusterBuilder { + b.Status.ShardCount = count + return b +} +func (b *ClusterBuilder) SetMongodsPerShardCountStatus(count int) *ClusterBuilder { + b.Status.MongodsPerShardCount = count + return b +} +func (b *ClusterBuilder) SetConfigServerCountStatus(count int) *ClusterBuilder { + b.Status.ConfigServerCount = count + return b +} + +func (b *ClusterBuilder) SetMongosCountStatus(count int) *ClusterBuilder { + b.Status.MongosCount = count + return b +} + +func (b *ClusterBuilder) SetSecurity(security mdbv1.Security) *ClusterBuilder { + b.Spec.Security = &security + return b +} + +func (b *ClusterBuilder) EnableTLS() *ClusterBuilder { + if b.Spec.Security == nil || b.Spec.Security.TLSConfig == nil { + return b.SetSecurity(mdbv1.Security{TLSConfig: &mdbv1.TLSConfig{Enabled: true}}) + } + b.Spec.Security.TLSConfig.Enabled = true + return b +} + +func (b *ClusterBuilder) SetTLSCA(ca string) *ClusterBuilder { + if b.Spec.Security == nil || b.Spec.Security.TLSConfig == nil { + b.SetSecurity(mdbv1.Security{TLSConfig: &mdbv1.TLSConfig{}}) + } + b.Spec.Security.TLSConfig.CA = ca + return b +} + +func (b *ClusterBuilder) SetTLSConfig(tlsConfig mdbv1.TLSConfig) *ClusterBuilder { + if b.Spec.Security == nil { + b.Spec.Security = &mdbv1.Security{} + } + b.Spec.Security.TLSConfig = &tlsConfig + return b +} + +func (b *ClusterBuilder) EnableX509() *ClusterBuilder { + b.Spec.Security.Authentication.Enabled = true + b.Spec.Security.Authentication.Modes = append(b.Spec.Security.Authentication.Modes, util.X509) + return b +} + +func (b *ClusterBuilder) EnableSCRAM() *ClusterBuilder { + b.Spec.Security.Authentication.Enabled = true + b.Spec.Security.Authentication.Modes = append(b.Spec.Security.Authentication.Modes, util.SCRAM) + return b +} + +func (b *ClusterBuilder) RemoveAuth() *ClusterBuilder { + b.Spec.Security.Authentication = nil + + return b +} + +func (b *ClusterBuilder) EnableAuth() *ClusterBuilder { + b.Spec.Security.Authentication.Enabled = true + return b +} + +func (b *ClusterBuilder) SetAuthModes(modes []string) *ClusterBuilder { + b.Spec.Security.Authentication.Modes = modes + return b +} + +func (b *ClusterBuilder) EnableX509InternalClusterAuth() *ClusterBuilder { + b.Spec.Security.Authentication.InternalCluster = util.X509 + return b +} + +func (b *ClusterBuilder) SetShardPodSpec(spec corev1.PodTemplateSpec) *ClusterBuilder { + if b.Spec.ShardPodSpec == nil { + b.Spec.ShardPodSpec = &mdbv1.MongoDbPodSpec{} + } + b.Spec.ShardPodSpec.PodTemplateWrapper.PodTemplate = &spec + return b +} + +func (b *ClusterBuilder) SetPodConfigSvrSpecTemplate(spec corev1.PodTemplateSpec) *ClusterBuilder { + if b.Spec.ConfigSrvPodSpec == nil { + b.Spec.ConfigSrvPodSpec = &mdbv1.MongoDbPodSpec{} + } + b.Spec.ConfigSrvPodSpec.PodTemplateWrapper.PodTemplate = &spec + return b +} + +func (b *ClusterBuilder) SetMongosPodSpecTemplate(spec corev1.PodTemplateSpec) *ClusterBuilder { + if b.Spec.MongosPodSpec == nil { + b.Spec.MongosPodSpec = &mdbv1.MongoDbPodSpec{} + } + b.Spec.MongosPodSpec.PodTemplateWrapper.PodTemplate = &spec + return b +} + +func (b *ClusterBuilder) SetShardSpecificPodSpecTemplate(specs []corev1.PodTemplateSpec) *ClusterBuilder { + if b.Spec.ShardSpecificPodSpec == nil { + b.Spec.ShardSpecificPodSpec = make([]mdbv1.MongoDbPodSpec, 0) + } + + mongoDBPodSpec := make([]mdbv1.MongoDbPodSpec, len(specs)) + + for n, e := range specs { + mongoDBPodSpec[n] = mdbv1.MongoDbPodSpec{PodTemplateWrapper: mdbv1.PodTemplateSpecWrapper{ + PodTemplate: &e, + }} + } + + b.Spec.ShardSpecificPodSpec = mongoDBPodSpec + return b +} + +func (b *ClusterBuilder) Build() *mdbv1.MongoDB { + b.Spec.ResourceType = mdbv1.ShardedCluster + b.InitDefaults() + return b.MongoDB +} + +func configMap() corev1.ConfigMap { + return configmap.Builder(). + SetName(om.TestGroupName). + SetNamespace(mock.TestNamespace). + SetDataField(util.OmBaseUrl, om.TestURL). + SetDataField(util.OmOrgId, om.TestOrgID). + SetDataField(util.OmProjectName, om.TestGroupName). + Build() +} diff --git a/controllers/operator/mongodbstandalone_controller.go b/controllers/operator/mongodbstandalone_controller.go new file mode 100644 index 000000000..e1d56a161 --- /dev/null +++ b/controllers/operator/mongodbstandalone_controller.go @@ -0,0 +1,364 @@ +package operator + +import ( + "context" + "fmt" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/annotations" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/util/merge" + "golang.org/x/xerrors" + + "k8s.io/apimachinery/pkg/api/errors" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/project" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/deployment" + + "github.com/10gen/ops-manager-kubernetes/pkg/dns" + + "github.com/10gen/ops-manager-kubernetes/pkg/vault" + "github.com/10gen/ops-manager-kubernetes/pkg/vault/vaultwatcher" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/create" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/certs" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/construct" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/pem" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/connection" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/controlledfeature" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/host" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + mdbstatus "github.com/10gen/ops-manager-kubernetes/api/v1/status" + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/agents" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/watch" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/workflow" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "go.uber.org/zap" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" +) + +// AddStandaloneController creates a new MongoDbStandalone Controller and adds it to the Manager. The Manager will set fields on the Controller +// and Start it when the Manager is Started. +func AddStandaloneController(mgr manager.Manager) error { + // Create a new controller + reconciler := newStandaloneReconciler(mgr, om.NewOpsManagerConnection) + c, err := controller.New(util.MongoDbStandaloneController, mgr, controller.Options{Reconciler: reconciler}) + if err != nil { + return err + } + + // watch for changes to standalone MongoDB resources + eventHandler := ResourceEventHandler{deleter: reconciler} + err = c.Watch(&source.Kind{Type: &mdbv1.MongoDB{}}, &eventHandler, watch.PredicatesForMongoDB(mdbv1.Standalone)) + if err != nil { + return err + } + + err = c.Watch(&source.Kind{Type: &corev1.ConfigMap{}}, + &watch.ResourcesHandler{ResourceType: watch.ConfigMap, TrackedResources: reconciler.WatchedResources}) + if err != nil { + return err + } + + err = c.Watch(&source.Kind{Type: &corev1.Secret{}}, + &watch.ResourcesHandler{ResourceType: watch.Secret, TrackedResources: reconciler.WatchedResources}) + if err != nil { + return err + } + + // if vault secret backend is enabled watch for Vault secret change and trigger reconcile + if vault.IsVaultSecretBackend() { + eventChannel := make(chan event.GenericEvent) + go vaultwatcher.WatchSecretChangeForMDB(zap.S(), eventChannel, reconciler.client, reconciler.VaultClient, mdbv1.Standalone) + + err = c.Watch( + &source.Channel{Source: eventChannel}, + &handler.EnqueueRequestForObject{}, + ) + if err != nil { + zap.S().Errorf("Failed to watch for vault secret changes: %w", err) + } + } + zap.S().Infof("Registered controller %s", util.MongoDbStandaloneController) + + return nil +} + +func newStandaloneReconciler(mgr manager.Manager, omFunc om.ConnectionFactory) *ReconcileMongoDbStandalone { + return &ReconcileMongoDbStandalone{ + ReconcileCommonController: newReconcileCommonController(mgr), + omConnectionFactory: omFunc, + } +} + +// ReconcileMongoDbStandalone reconciles a MongoDbStandalone object +type ReconcileMongoDbStandalone struct { + *ReconcileCommonController + omConnectionFactory om.ConnectionFactory +} + +func (r *ReconcileMongoDbStandalone) Reconcile(_ context.Context, request reconcile.Request) (res reconcile.Result, e error) { + agents.UpgradeAllIfNeeded(r.client, r.SecretClient, r.omConnectionFactory, GetWatchedNamespace()) + + log := zap.S().With("Standalone", request.NamespacedName) + s := &mdbv1.MongoDB{} + + if reconcileResult, err := r.prepareResourceForReconciliation(request, s, log); err != nil { + if errors.IsNotFound(err) { + return reconcile.Result{}, nil + } + return reconcileResult, err + } + + if err := s.ProcessValidationsOnReconcile(nil); err != nil { + return r.updateStatus(s, workflow.Invalid(err.Error()), log) + } + + log.Info("-> Standalone.Reconcile") + log.Infow("Standalone.Spec", "spec", s.Spec) + log.Infow("Standalone.Status", "status", s.Status) + + projectConfig, credsConfig, err := project.ReadConfigAndCredentials(r.client, r.SecretClient, s, log) + if err != nil { + return r.updateStatus(s, workflow.Failed(err), log) + } + + conn, err := connection.PrepareOpsManagerConnection(r.SecretClient, projectConfig, credsConfig, r.omConnectionFactory, s.Namespace, log) + if err != nil { + return r.updateStatus(s, workflow.Failed(xerrors.Errorf("Failed to prepare Ops Manager connection: %w", err)), log) + } + + if status := ensureSupportedOpsManagerVersion(conn); status.Phase() != mdbstatus.PhaseRunning { + return r.updateStatus(s, status, log) + } + + r.SetupCommonWatchers(s, nil, nil, s.Name) + + reconcileResult := checkIfHasExcessProcesses(conn, s, log) + if !reconcileResult.IsOK() { + return r.updateStatus(s, reconcileResult, log) + } + + if status := controlledfeature.EnsureFeatureControls(*s, conn, conn.OpsManagerVersion(), log); !status.IsOK() { + return r.updateStatus(s, status, log) + } + + // cannot have a non-tls deployment in an x509 environment + // TODO move to webhook validations + security := s.Spec.Security + if security.Authentication != nil && security.Authentication.Enabled && security.Authentication.IsX509Enabled() && !s.Spec.GetSecurity().IsTLSEnabled() { + return r.updateStatus(s, workflow.Invalid("cannot have a non-tls deployment when x509 authentication is enabled"), log) + } + + currentAgentAuthMode, err := conn.GetAgentAuthMode() + if err != nil { + return r.updateStatus(s, workflow.Failed(err), log) + } + + podVars := newPodVars(conn, projectConfig, s.Spec.ConnectionSpec) + + if status := validateMongoDBResource(s, conn); !status.IsOK() { + return r.updateStatus(s, status, log) + } + + if status := certs.EnsureSSLCertsForStatefulSet(r.SecretClient, r.SecretClient, *s.Spec.Security, certs.StandaloneConfig(*s), log); !status.IsOK() { + return r.updateStatus(s, status, log) + } + + // TODO separate PR + certConfigurator := certs.StandaloneX509CertConfigurator{MongoDB: s, SecretClient: r.SecretClient} + if status := r.ensureX509SecretAndCheckTLSType(certConfigurator, currentAgentAuthMode, log); !status.IsOK() { + return r.updateStatus(s, status, log) + } + + if status := ensureRoles(s.Spec.GetSecurity().Roles, conn, log); !status.IsOK() { + return r.updateStatus(s, status, log) + } + + var vaultConfig vault.VaultConfiguration + if r.VaultClient != nil { + vaultConfig = r.VaultClient.VaultConfig + } + standaloneCertSecretName := certs.StandaloneConfig(*s).CertSecretName + + var databaseSecretPath string + if r.VaultClient != nil { + databaseSecretPath = r.VaultClient.DatabaseSecretPath() + } + standaloneOpts := construct.StandaloneOptions( + CertificateHash(pem.ReadHashFromSecret(r.SecretClient, s.Namespace, standaloneCertSecretName, databaseSecretPath, log)), + CurrentAgentAuthMechanism(currentAgentAuthMode), + PodEnvVars(podVars), + WithVaultConfig(vaultConfig), + ) + + sts := construct.DatabaseStatefulSet(*s, standaloneOpts, nil) + + status := workflow.RunInGivenOrder(needToPublishStateFirst(r.client, *s, standaloneOpts, log), + func() workflow.Status { + return r.updateOmDeployment(conn, s, sts, log).OnErrorPrepend("Failed to create/update (Ops Manager reconciliation phase):") + }, + func() workflow.Status { + if err = create.DatabaseInKubernetes(r.client, *s, sts, standaloneOpts, log); err != nil { + return workflow.Failed(xerrors.Errorf("Failed to create/update (Kubernetes reconciliation phase): %w", err)) + } + + if status := getStatefulSetStatus(sts.Namespace, sts.Name, r.client); !status.IsOK() { + return status + } + _, _ = r.updateStatus(s, workflow.Reconciling().WithResourcesNotReady([]mdbstatus.ResourceNotReady{}).WithNoMessage(), log) + + log.Info("Updated StatefulSet for standalone") + return workflow.OK() + }) + + if !status.IsOK() { + return r.updateStatus(s, status, log) + } + + annotationsToAdd, err := getAnnotationsForResource(s) + if err != nil { + return r.updateStatus(s, workflow.Failed(err), log) + } + + if vault.IsVaultSecretBackend() { + secrets := s.GetSecretsMountedIntoDBPod() + vaultMap := make(map[string]string) + for _, secret := range secrets { + path := fmt.Sprintf("%s/%s/%s", r.VaultClient.DatabaseSecretMetadataPath(), s.Namespace, secret) + vaultMap = merge.StringToStringMap(vaultMap, r.VaultClient.GetSecretAnnotation(path)) + } + path := fmt.Sprintf("%s/%s/%s", r.VaultClient.OperatorScretMetadataPath(), s.Namespace, s.Spec.Credentials) + vaultMap = merge.StringToStringMap(vaultMap, r.VaultClient.GetSecretAnnotation(path)) + for k, val := range vaultMap { + annotationsToAdd[k] = val + } + } + if err := annotations.SetAnnotations(s, annotationsToAdd, r.client); err != nil { + return r.updateStatus(s, workflow.Failed(err), log) + } + + log.Infof("Finished reconciliation for MongoDbStandalone! %s", completionMessage(conn.BaseURL(), conn.GroupID())) + return r.updateStatus(s, status, log, mdbstatus.NewBaseUrlOption(deployment.Link(conn.BaseURL(), conn.GroupID()))) +} + +func (r *ReconcileMongoDbStandalone) updateOmDeployment(conn om.Connection, s *mdbv1.MongoDB, + set appsv1.StatefulSet, log *zap.SugaredLogger) workflow.Status { + if err := agents.WaitForRsAgentsToRegister(set, 0, s.Spec.GetClusterDomain(), conn, log, s); err != nil { + return workflow.Failed(err) + } + + // TODO standalone PR + status, additionalReconciliationRequired := r.updateOmAuthentication(conn, []string{set.Name}, s, "", "", "", log) + if !status.IsOK() { + return status + } + + standaloneOmObject := createProcess(set, util.DatabaseContainerName, s) + err := conn.ReadUpdateDeployment( + func(d om.Deployment) error { + excessProcesses := d.GetNumberOfExcessProcesses(s.Name) + if excessProcesses > 0 { + return xerrors.Errorf("cannot have more than 1 MongoDB Cluster per project (see https://docs.mongodb.com/kubernetes-operator/stable/tutorial/migrate-to-single-resource/)") + } + + lastStandaloneConfig, err := s.GetLastAdditionalMongodConfigByType(mdbv1.StandaloneConfig) + if err != nil { + return err + } + + d.MergeStandalone(standaloneOmObject, s.Spec.AdditionalMongodConfig.ToMap(), lastStandaloneConfig.ToMap(), nil) + // TODO change last argument in separate PR + d.AddMonitoringAndBackup(log, s.Spec.GetSecurity().IsTLSEnabled(), util.CAFilePathInContainer) + d.ConfigureTLS(s.Spec.GetSecurity(), util.CAFilePathInContainer) + return nil + }, + log, + ) + + if err != nil { + return workflow.Failed(err) + } + + if err := om.WaitForReadyState(conn, []string{set.Name}, log); err != nil { + return workflow.Failed(err) + } + + if additionalReconciliationRequired { + return workflow.Pending("Performing multi stage reconciliation") + } + + log.Info("Updated Ops Manager for standalone") + return workflow.OK() + +} + +func (r *ReconcileMongoDbStandalone) OnDelete(obj runtime.Object, log *zap.SugaredLogger) error { + s := obj.(*mdbv1.MongoDB) + + log.Infow("Removing standalone from Ops Manager", "config", s.Spec) + + projectConfig, credsConfig, err := project.ReadConfigAndCredentials(r.client, r.SecretClient, s, log) + if err != nil { + return err + } + + conn, err := connection.PrepareOpsManagerConnection(r.SecretClient, projectConfig, credsConfig, r.omConnectionFactory, s.Namespace, log) + if err != nil { + return err + } + + processNames := make([]string, 0) + err = conn.ReadUpdateDeployment( + func(d om.Deployment) error { + processNames = d.GetProcessNames(om.Standalone{}, s.Name) + // error means that process is not in the deployment - it's ok and we can proceed (could happen if + // deletion cleanup happened twice and the first one cleaned OM state already) + if e := d.RemoveProcessByName(s.Name, log); e != nil { + log.Warnf("Failed to remove standalone from automation config: %s", e) + } + return nil + }, + log, + ) + if err != nil { + return xerrors.Errorf("failed to update Ops Manager automation config: %w", err) + } + + if err := om.WaitForReadyState(conn, processNames, log); err != nil { + return err + } + + hostsToRemove, _ := dns.GetDNSNames(s.Name, s.ServiceName(), s.Namespace, s.Spec.GetClusterDomain(), 1, nil) + log.Infow("Stop monitoring removed hosts", "removedHosts", hostsToRemove) + if err = host.StopMonitoring(conn, hostsToRemove, log); err != nil { + return err + } + if err := r.clearProjectAuthenticationSettings(conn, s, processNames, log); err != nil { + return err + } + + r.RemoveDependentWatchedResources(s.ObjectKey()) + + log.Info("Removed standalone from Ops Manager!") + return nil +} + +func createProcess(set appsv1.StatefulSet, containerName string, s *mdbv1.MongoDB) om.Process { + hostnames, _ := dns.GetDnsForStatefulSet(set, s.Spec.GetClusterDomain(), nil) + process := om.NewMongodProcess(0, s.Name, hostnames[0], s.Spec.GetAdditionalMongodConfig(), s.GetSpec(), "") + return process +} diff --git a/controllers/operator/mongodbstandalone_controller_test.go b/controllers/operator/mongodbstandalone_controller_test.go new file mode 100644 index 000000000..d8268db7c --- /dev/null +++ b/controllers/operator/mongodbstandalone_controller_test.go @@ -0,0 +1,291 @@ +package operator + +import ( + "reflect" + "testing" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/construct" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/watch" + "k8s.io/apimachinery/pkg/types" + + "github.com/10gen/ops-manager-kubernetes/pkg/dns" + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "github.com/10gen/ops-manager-kubernetes/pkg/util/versionutil" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/controlledfeature" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/mock" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/stretchr/testify/assert" + "go.uber.org/zap" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestCreateOmProcess(t *testing.T) { + sts := construct.DatabaseStatefulSet(*DefaultReplicaSetBuilder().SetName("dublin").Build(), construct.StandaloneOptions(construct.GetPodEnvOptions()), nil) + process := createProcess(sts, util.DatabaseContainerName, DefaultStandaloneBuilder().Build()) + // Note, that for standalone the name of process is the name of statefulset - not the pod inside it. + assert.Equal(t, "dublin", process.Name()) + assert.Equal(t, "dublin-0.dublin-svc.my-namespace.svc.cluster.local", process.HostName()) + assert.Equal(t, "4.0.0", process.Version()) +} + +func TestOnAddStandalone(t *testing.T) { + st := DefaultStandaloneBuilder().SetVersion("4.1.0").SetService("mysvc").Build() + + reconciler, client := defaultStandaloneReconciler(st) + + checkReconcileSuccessful(t, reconciler, st, client) + + omConn := om.CurrMockedConnection + + // seems we don't need very deep checks here as there should be smaller tests specially for those methods + assert.Len(t, client.GetMapForObject(&corev1.Service{}), 1) + assert.Len(t, client.GetMapForObject(&appsv1.StatefulSet{}), 1) + assert.Equal(t, *client.GetMapForObject(&appsv1.StatefulSet{})[st.ObjectKey()].(*appsv1.StatefulSet).Spec.Replicas, int32(1)) + assert.Len(t, client.GetMapForObject(&corev1.Secret{}), 2) + + omConn.CheckDeployment(t, createDeploymentFromStandalone(st), "auth", "tls") + omConn.CheckNumberOfUpdateRequests(t, 1) +} + +// TestOnAddStandaloneWithDelay checks the reconciliation on standalone creation with some "delay" in getting +// StatefulSet ready. The first reconciliation gets to Pending while the second reconciliation suceeds +func TestOnAddStandaloneWithDelay(t *testing.T) { + st := DefaultStandaloneBuilder().SetVersion("4.1.0").SetService("mysvc").Build() + + client := mock.NewClient().WithResource(st).WithStsReady(false).AddDefaultMdbConfigResources() + manager := mock.NewManagerSpecificClient(client) + + reconciler := newStandaloneReconciler(manager, om.NewEmptyMockedOmConnection) + + checkReconcilePending(t, reconciler, st, "StatefulSet not ready", client, 3) + client.WithStsReady(true) + + checkReconcileSuccessful(t, reconciler, st, client) +} + +// TestAddDeleteStandalone checks that no state is left in OpsManager on removal of the standalone +func TestAddDeleteStandalone(t *testing.T) { + // First we need to create a standalone + st := DefaultStandaloneBuilder().SetVersion("4.0.0").Build() + + reconciler, client := defaultStandaloneReconciler(st) + + checkReconcileSuccessful(t, reconciler, st, client) + + // Now delete it + assert.NoError(t, reconciler.OnDelete(st, zap.S())) + + omConn := om.CurrMockedConnection + // Operator doesn't mutate K8s state, so we don't check its changes, only OM + omConn.CheckResourcesDeleted(t) + + // Note, that 'omConn.ReadAutomationStatus' happened twice - because the connection emulates agents delay in reaching goal state + omConn.CheckOrderOfOperations(t, + reflect.ValueOf(omConn.ReadUpdateDeployment), reflect.ValueOf(omConn.ReadAutomationStatus), + reflect.ValueOf(omConn.ReadAutomationStatus), reflect.ValueOf(omConn.GetHosts), reflect.ValueOf(omConn.RemoveHost)) + +} + +func TestStandaloneAuthenticationOwnedByOpsManager(t *testing.T) { + stBuilder := DefaultStandaloneBuilder() + stBuilder.Spec.Security = nil + st := stBuilder.Build() + + reconciler, client := defaultStandaloneReconciler(st) + reconciler.omConnectionFactory = func(context *om.OMContext) om.Connection { + context.Version = versionutil.OpsManagerVersion{ + VersionString: "5.0.0", + } + conn := om.NewEmptyMockedOmConnection(context) + return conn + } + + checkReconcileSuccessful(t, reconciler, st, client) + + mockedConn := om.CurrMockedConnection + cf, _ := mockedConn.GetControlledFeature() + + assert.Len(t, cf.Policies, 2) + assert.Equal(t, cf.ManagementSystem.Version, util.OperatorVersion) + assert.Equal(t, cf.ManagementSystem.Name, util.OperatorName) + assert.Equal(t, cf.Policies[0].PolicyType, controlledfeature.ExternallyManaged) + assert.Len(t, cf.Policies[0].DisabledParams, 0) +} + +func TestStandaloneAuthenticationOwnedByOperator(t *testing.T) { + st := DefaultStandaloneBuilder().Build() + + reconciler, client := defaultStandaloneReconciler(st) + reconciler.omConnectionFactory = func(context *om.OMContext) om.Connection { + context.Version = versionutil.OpsManagerVersion{ + VersionString: "5.0.0", + } + conn := om.NewEmptyMockedOmConnection(context) + return conn + } + + checkReconcileSuccessful(t, reconciler, st, client) + + mockedConn := om.CurrMockedConnection + cf, _ := mockedConn.GetControlledFeature() + + assert.Len(t, cf.Policies, 3) + assert.Equal(t, cf.ManagementSystem.Version, util.OperatorVersion) + assert.Equal(t, cf.ManagementSystem.Name, util.OperatorName) + + var policies []controlledfeature.PolicyType + for _, p := range cf.Policies { + policies = append(policies, p.PolicyType) + } + + assert.Contains(t, policies, controlledfeature.ExternallyManaged) + assert.Contains(t, policies, controlledfeature.DisableAuthenticationMechanisms) + +} + +func TestStandalonePortIsConfigurable_WithAdditionalMongoConfig(t *testing.T) { + config := mdbv1.NewAdditionalMongodConfig("net.port", 30000) + st := mdbv1.NewStandaloneBuilder(). + SetNamespace(mock.TestNamespace). + SetAdditionalConfig(config). + SetConnectionSpec(testConnectionSpec()). + Build() + + reconciler, client := defaultStandaloneReconciler(st) + + checkReconcileSuccessful(t, reconciler, st, client) + + svc, err := client.GetService(kube.ObjectKey(st.Namespace, st.ServiceName())) + assert.NoError(t, err) + assert.Equal(t, int32(30000), svc.Spec.Ports[0].Port) +} + +func TestStandaloneCustomPodSpecTemplate(t *testing.T) { + st := DefaultStandaloneBuilder().SetPodSpecTemplate(corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"first": "val"}}, + }).Build() + + reconciler, client := defaultStandaloneReconciler(st) + + checkReconcileSuccessful(t, reconciler, st, client) + + statefulSet, err := client.GetStatefulSet(mock.ObjectKeyFromApiObject(st)) + assert.NoError(t, err) + + expectedLabels := map[string]string{"app": "dublin-svc", "controller": "mongodb-enterprise-operator", + "first": "val", "pod-anti-affinity": "dublin"} + assert.Equal(t, expectedLabels, statefulSet.Spec.Template.Labels) +} + +// TestStandalone_ConfigMapAndSecretWatched +func TestStandalone_ConfigMapAndSecretWatched(t *testing.T) { + s := DefaultStandaloneBuilder().Build() + + reconciler, client := defaultStandaloneReconciler(s) + + checkReconcileSuccessful(t, reconciler, s, client) + + expected := map[watch.Object][]types.NamespacedName{ + {ResourceType: watch.ConfigMap, Resource: kube.ObjectKey(mock.TestNamespace, mock.TestProjectConfigMapName)}: {kube.ObjectKey(mock.TestNamespace, s.Name)}, + {ResourceType: watch.Secret, Resource: kube.ObjectKey(mock.TestNamespace, s.Spec.Credentials)}: {kube.ObjectKey(mock.TestNamespace, s.Name)}, + } + + actual := reconciler.WatchedResources + assert.Equal(t, expected, actual) +} + +// defaultStandaloneReconciler is the standalone reconciler used in unit test. It "adds" necessary +// additional K8s objects (st, connection config map and secrets) necessary for reconciliation, +// so it's possible to call 'reconcileAppDB()' on it right away +func defaultStandaloneReconciler(rs *mdbv1.MongoDB) (*ReconcileMongoDbStandalone, *mock.MockedClient) { + manager := mock.NewManager(rs) + manager.Client.AddDefaultMdbConfigResources() + + return newStandaloneReconciler(manager, om.NewEmptyMockedOmConnection), manager.Client +} + +// TODO remove in favor of '/api/mongodbbuilder.go' +type StandaloneBuilder struct { + *mdbv1.MongoDB +} + +func DefaultStandaloneBuilder() *StandaloneBuilder { + spec := mdbv1.MongoDbSpec{ + DbCommonSpec: mdbv1.DbCommonSpec{ + Version: "4.0.0", + Persistent: util.BooleanRef(true), + ConnectionSpec: mdbv1.ConnectionSpec{ + SharedConnectionSpec: mdbv1.SharedConnectionSpec{ + OpsManagerConfig: &mdbv1.PrivateCloudConfig{ + ConfigMapRef: mdbv1.ConfigMapRef{ + Name: mock.TestProjectConfigMapName, + }, + }, + }, Credentials: mock.TestCredentialsSecretName, + }, + Security: &mdbv1.Security{ + Authentication: &mdbv1.Authentication{ + Modes: []string{}, + }, + TLSConfig: &mdbv1.TLSConfig{}, + }, + ResourceType: mdbv1.Standalone, + }, + Members: 1, + } + resource := &mdbv1.MongoDB{ObjectMeta: metav1.ObjectMeta{Name: "dublin", Namespace: mock.TestNamespace}, Spec: spec} + return &StandaloneBuilder{resource} +} + +func (b *StandaloneBuilder) SetName(name string) *StandaloneBuilder { + b.Name = name + return b +} +func (b *StandaloneBuilder) SetVersion(version string) *StandaloneBuilder { + b.Spec.Version = version + return b +} +func (b *StandaloneBuilder) SetPersistent(p *bool) *StandaloneBuilder { + b.Spec.Persistent = p + return b +} +func (b *StandaloneBuilder) SetService(s string) *StandaloneBuilder { + b.Spec.Service = s + return b +} + +func (b *StandaloneBuilder) SetPodSpecTemplate(spec corev1.PodTemplateSpec) *StandaloneBuilder { + if b.Spec.PodSpec == nil { + b.Spec.PodSpec = &mdbv1.MongoDbPodSpec{} + } + b.Spec.PodSpec.PodTemplateWrapper.PodTemplate = &spec + return b +} + +func (b *StandaloneBuilder) Build() *mdbv1.MongoDB { + b.Spec.ResourceType = mdbv1.Standalone + b.InitDefaults() + return b.MongoDB.DeepCopy() +} + +func createDeploymentFromStandalone(st *mdbv1.MongoDB) om.Deployment { + d := om.NewDeployment() + sts := construct.DatabaseStatefulSet(*st, construct.StandaloneOptions(construct.GetPodEnvOptions()), nil) + hostnames, _ := dns.GetDnsForStatefulSet(sts, st.Spec.GetClusterDomain(), nil) + process := om.NewMongodProcess(0, st.Name, hostnames[0], st.Spec.AdditionalMongodConfig, st.GetSpec(), "") + + lastConfig, err := st.GetLastAdditionalMongodConfigByType(mdbv1.StandaloneConfig) + if err != nil { + panic(err) + } + + d.MergeStandalone(process, st.Spec.AdditionalMongodConfig.ToMap(), lastConfig.ToMap(), nil) + d.AddMonitoringAndBackup(zap.S(), st.Spec.GetSecurity().IsTLSEnabled(), util.CAFilePathInContainer) + return d +} diff --git a/controllers/operator/mongodbuser_controller.go b/controllers/operator/mongodbuser_controller.go new file mode 100644 index 000000000..ddef0bd15 --- /dev/null +++ b/controllers/operator/mongodbuser_controller.go @@ -0,0 +1,417 @@ +package operator + +import ( + "context" + "encoding/json" + "time" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/annotations" + "golang.org/x/xerrors" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/connection" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/connectionstring" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/project" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/secrets" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/secret" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/watch" + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/api/v1/mdbmulti" + userv1 "github.com/10gen/ops-manager-kubernetes/api/v1/user" + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/authentication" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/workflow" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + kubernetesClient "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/client" + "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/cluster" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" +) + +type ClusterType string + +const ( + Single = "single" + Multi = "multi" +) + +type MongoDBUserReconciler struct { + *ReconcileCommonController + omConnectionFactory om.ConnectionFactory + memberClusterClientsMap map[string]kubernetesClient.Client + memberClusterSecretClientsMap map[string]secrets.SecretClient +} + +func newMongoDBUserReconciler(mgr manager.Manager, omFunc om.ConnectionFactory, memberClustersMap map[string]cluster.Cluster) *MongoDBUserReconciler { + clientsMap := make(map[string]kubernetesClient.Client) + secretClientsMap := make(map[string]secrets.SecretClient) + + for k, v := range memberClustersMap { + clientsMap[k] = kubernetesClient.NewClient(v.GetClient()) + secretClientsMap[k] = secrets.SecretClient{ + VaultClient: nil, + KubeClient: clientsMap[k], + } + } + return &MongoDBUserReconciler{ + ReconcileCommonController: newReconcileCommonController(mgr), + omConnectionFactory: omFunc, + memberClusterClientsMap: clientsMap, + memberClusterSecretClientsMap: secretClientsMap, + } +} + +func (r *MongoDBUserReconciler) getUser(request reconcile.Request, log *zap.SugaredLogger) (*userv1.MongoDBUser, error) { + user := &userv1.MongoDBUser{} + if _, err := r.getResource(request, user, log); err != nil { + return nil, err + } + + // if database isn't specified default to the admin database, the recommended + // place for creating non-$external users + if user.Spec.Database == "" { + user.Spec.Database = "admin" + } + + return user, nil +} + +// getMongoDB return a MongoDB deployment of type Single or Multi cluster based on the clusterType passed +func (r *MongoDBUserReconciler) getMongoDB(user userv1.MongoDBUser) (project.Reader, error) { + name := kube.ObjectKey(user.Namespace, user.Spec.MongoDBResourceRef.Name) + + // Try the single cluster resource + mdb := &mdbv1.MongoDB{} + if err := r.client.Get(context.TODO(), name, mdb); err == nil { + return mdb, nil + } + + // Try the multi-cluster next + mdbm := &mdbmulti.MongoDBMultiCluster{} + err := r.client.Get(context.TODO(), name, mdbm) + return mdbm, err +} + +// getMongoDBConnectionBuilder returns an object that can construct a MongoDB Connection String on itself. +func (r *MongoDBUserReconciler) getMongoDBConnectionBuilder(user userv1.MongoDBUser) (connectionstring.ConnectionStringBuilder, error) { + name := kube.ObjectKey(user.Namespace, user.Spec.MongoDBResourceRef.Name) + + // Try single cluster resource + mdb := &mdbv1.MongoDB{} + if err := r.client.Get(context.TODO(), name, mdb); err == nil { + return mdb, nil + } + + // Try the multi-cluster next + mdbm := &mdbmulti.MongoDBMultiCluster{} + err := r.client.Get(context.TODO(), name, mdbm) + return mdbm, err +} + +// +kubebuilder:rbac:groups=mongodb.com,resources={mongodbusers,mongodbusers/status,mongodbusers/finalizers},verbs=*,namespace=placeholder + +// Reconciles a mongodbusers.mongodb.com Custom resource. +func (r *MongoDBUserReconciler) Reconcile(_ context.Context, request reconcile.Request) (res reconcile.Result, e error) { + log := zap.S().With("MongoDBUser", request.NamespacedName) + log.Info("-> MongoDBUser.Reconcile") + + user, err := r.getUser(request, log) + if err != nil { + log.Warnf("error getting user %s", err) + return reconcile.Result{RequeueAfter: time.Second * util.RetryTimeSec}, nil + } + + log.Infow("MongoDBUser.Spec", "spec", user.Spec) + var mdb project.Reader + + if user.Spec.MongoDBResourceRef.Name != "" { + if mdb, err = r.getMongoDB(*user); err != nil { + log.Warnf("Couldn't fetch MongoDB Single/Multi Cluster Resource with name: %s, err: %s", user.Spec.MongoDBResourceRef.Name, err) + return r.updateStatus(user, workflow.Pending(err.Error()), log) + } + } else { + log.Warn("MongoDB reference not specified. Using deprecated project field.") + } + + // this can happen when a user has registered a configmap as watched resource + // but the user gets deleted. Reconciliation happens to this user even though it is deleted. + // TODO: unregister config map upon MongoDBUser deletion + if user.Namespace == "" && user.Name == "" { + // stop reconciliation + return reconcile.Result{}, nil + } + + projectConfig, credsConfig, err := project.ReadConfigAndCredentials(r.client, r.SecretClient, mdb, log) + if err != nil { + return r.updateStatus(user, workflow.Failed(err), log) + } + + conn, err := connection.PrepareOpsManagerConnection(r.SecretClient, projectConfig, credsConfig, r.omConnectionFactory, user.Namespace, log) + if err != nil { + return r.updateStatus(user, workflow.Failed(xerrors.Errorf("Failed to prepare Ops Manager connection: %w", err)), log) + } + + if err = r.updateConnectionStringSecret(*user, log); err != nil { + return r.updateStatus(user, workflow.Failed(err), log) + } + + if user.Spec.Database == authentication.ExternalDB { + return r.handleExternalAuthUser(user, conn, log) + } else { + return r.handleScramShaUser(user, conn, log) + } +} + +func (r *MongoDBUserReconciler) delete(obj interface{}, log *zap.SugaredLogger) error { + user := obj.(*userv1.MongoDBUser) + + mdb, err := r.getMongoDB(*user) + if err != nil { + return err + } + + projectConfig, credsConfig, err := project.ReadConfigAndCredentials(r.client, r.SecretClient, mdb, log) + if err != nil { + return err + } + + conn, err := connection.PrepareOpsManagerConnection(r.SecretClient, projectConfig, credsConfig, r.omConnectionFactory, user.Namespace, log) + if err != nil { + log.Errorf("Failed to prepare Ops Manager connection: %s", err) + return err + } + + r.RemoveAllDependentWatchedResources(user.Namespace, kube.ObjectKeyFromApiObject(user)) + + return conn.ReadUpdateAutomationConfig(func(ac *om.AutomationConfig) error { + ac.Auth.EnsureUserRemoved(user.Spec.Username, user.Spec.Database) + return nil + }, log) +} + +func (r *MongoDBUserReconciler) updateConnectionStringSecret(user userv1.MongoDBUser, log *zap.SugaredLogger) error { + var err error + var password string + + if user.Spec.Database != authentication.ExternalDB { + password, err = user.GetPassword(r.SecretClient) + if err != nil { + log.Debug("User does not have a configured password.") + } + } + + connectionBuilder, err := r.getMongoDBConnectionBuilder(user) + if err != nil { + return err + } + + secretName := user.GetConnectionStringSecretName() + existingSecret, err := r.client.GetSecret(types.NamespacedName{Name: secretName, Namespace: user.Namespace}) + if err != nil && !apiErrors.IsNotFound(err) { + return err + } + if err == nil && !secret.HasOwnerReferences(existingSecret, user.GetOwnerReferences()) { + return xerrors.Errorf("connection string secret %s already exists and is not managed by the operator", secretName) + } + + mongoAuthUserURI := connectionBuilder.BuildConnectionString(user.Spec.Username, password, connectionstring.SchemeMongoDB, map[string]string{}) + mongoAuthUserSRVURI := connectionBuilder.BuildConnectionString(user.Spec.Username, password, connectionstring.SchemeMongoDBSRV, map[string]string{}) + + connectionStringSecret := secret.Builder(). + SetName(secretName). + SetNamespace(user.Namespace). + SetField("connectionString.standard", mongoAuthUserURI). + SetField("connectionString.standardSrv", mongoAuthUserSRVURI). + SetField("username", user.Spec.Username). + SetField("password", password). + SetOwnerReferences(user.GetOwnerReferences()). + Build() + + for _, c := range r.memberClusterSecretClientsMap { + err = secret.CreateOrUpdate(c, connectionStringSecret) + if err != nil { + return err + } + } + return secret.CreateOrUpdate(r.SecretClient, connectionStringSecret) +} + +func AddMongoDBUserController(mgr manager.Manager, memberClustersMap map[string]cluster.Cluster) error { + reconciler := newMongoDBUserReconciler(mgr, om.NewOpsManagerConnection, memberClustersMap) + c, err := controller.New(util.MongoDbUserController, mgr, controller.Options{Reconciler: reconciler}) + if err != nil { + return err + } + + err = c.Watch(&source.Kind{Type: &corev1.ConfigMap{}}, + &watch.ResourcesHandler{ResourceType: watch.ConfigMap, TrackedResources: reconciler.WatchedResources}) + if err != nil { + return err + } + + err = c.Watch(&source.Kind{Type: &corev1.Secret{}}, + &watch.ResourcesHandler{ResourceType: watch.Secret, TrackedResources: reconciler.WatchedResources}) + if err != nil { + return err + } + + // watch for changes to MongoDBUser resources + eventHandler := MongoDBUserEventHandler{reconciler: reconciler} + err = c.Watch(&source.Kind{Type: &userv1.MongoDBUser{}}, &eventHandler, watch.PredicatesForUser()) + if err != nil { + return err + } + + zap.S().Infof("Registered controller %s", util.MongoDbUserController) + return nil +} + +// toOmUser converts a MongoDBUser specification and optional password into an +// automation config MongoDB user. If the user has no password then a blank +// password should be provided. +func toOmUser(spec userv1.MongoDBUserSpec, password string) (om.MongoDBUser, error) { + user := om.MongoDBUser{ + Database: spec.Database, + Username: spec.Username, + Roles: []*om.Role{}, + AuthenticationRestrictions: []string{}, + Mechanisms: []string{}, + } + + // only specify password if we're dealing with non-x509 users + if spec.Database != authentication.ExternalDB { + if err := authentication.ConfigureScramCredentials(&user, password); err != nil { + return om.MongoDBUser{}, xerrors.Errorf("error generating SCRAM credentials: %w", err) + } + } + + for _, r := range spec.Roles { + user.AddRole(&om.Role{Role: r.RoleName, Database: r.Database}) + } + return user, nil +} + +func (r *MongoDBUserReconciler) handleScramShaUser(user *userv1.MongoDBUser, conn om.Connection, log *zap.SugaredLogger) (res reconcile.Result, e error) { + // watch the password secret in order to trigger reconciliation if the + // password is updated + if user.Spec.PasswordSecretKeyRef.Name != "" { + r.AddWatchedResourceIfNotAdded( + user.Spec.PasswordSecretKeyRef.Name, + user.Namespace, + watch.Secret, + kube.ObjectKeyFromApiObject(user), + ) + } + + shouldRetry := false + err := conn.ReadUpdateAutomationConfig(func(ac *om.AutomationConfig) error { + if ac.Auth.Disabled || + (!stringutil.ContainsAny(ac.Auth.DeploymentAuthMechanisms, util.AutomationConfigScramSha256Option, util.AutomationConfigScramSha1Option)) { + shouldRetry = true + return xerrors.Errorf("scram Sha has not yet been configured") + } + + password, err := user.GetPassword(r.SecretClient) + if err != nil { + return err + } + + auth := ac.Auth + if user.ChangedIdentifier() { // we've changed username or database, we need to remove the old user before adding new + auth.RemoveUser(user.Status.Username, user.Status.Database) + } + + desiredUser, err := toOmUser(user.Spec, password) + if err != nil { + return err + } + + auth.EnsureUser(desiredUser) + return nil + }, log) + + if err != nil { + if shouldRetry { + return r.updateStatus(user, workflow.Pending(err.Error()).WithRetry(10), log) + } + return r.updateStatus(user, workflow.Failed(xerrors.Errorf("error updating user %w", err)), log) + } + + annotationsToAdd, err := getAnnotationsForUserResource(user) + if err != nil { + return r.updateStatus(user, workflow.Failed(err), log) + } + + if err := annotations.SetAnnotations(user, annotationsToAdd, r.client); err != nil { + return r.updateStatus(user, workflow.Failed(err), log) + } + + log.Infof("Finished reconciliation for MongoDBUser!") + return r.updateStatus(user, workflow.OK(), log) +} + +func (r *MongoDBUserReconciler) handleExternalAuthUser(user *userv1.MongoDBUser, conn om.Connection, log *zap.SugaredLogger) (reconcile.Result, error) { + desiredUser, err := toOmUser(user.Spec, "") + if err != nil { + return r.updateStatus(user, workflow.Failed(xerrors.Errorf("error updating user %w", err)), log) + } + + shouldRetry := false + updateFunction := func(ac *om.AutomationConfig) error { + if !externalAuthMechanismsAvailable(ac.Auth.DeploymentAuthMechanisms) { + shouldRetry = true + return xerrors.Errorf("no external authentication mechanisms (LDAP or x509) have been configured") + } + + auth := ac.Auth + if user.ChangedIdentifier() { + auth.RemoveUser(user.Status.Username, user.Status.Database) + } + + auth.EnsureUser(desiredUser) + return nil + } + + err = conn.ReadUpdateAutomationConfig(updateFunction, log) + if err != nil { + if shouldRetry { + return r.updateStatus(user, workflow.Pending(err.Error()).WithRetry(10), log) + } + return r.updateStatus(user, workflow.Failed(xerrors.Errorf("error updating user %w", err)), log) + } + + annotationsToAdd, err := getAnnotationsForUserResource(user) + if err != nil { + return r.updateStatus(user, workflow.Failed(err), log) + } + + if err := annotations.SetAnnotations(user, annotationsToAdd, r.client); err != nil { + return r.updateStatus(user, workflow.Failed(err), log) + } + + log.Infow("Finished reconciliation for MongoDBUser!") + return r.updateStatus(user, workflow.OK(), log) +} + +func externalAuthMechanismsAvailable(mechanisms []string) bool { + return stringutil.ContainsAny(mechanisms, util.AutomationConfigLDAPOption, util.AutomationConfigX509Option) +} + +func getAnnotationsForUserResource(user *userv1.MongoDBUser) (map[string]string, error) { + finalAnnotations := make(map[string]string) + specBytes, err := json.Marshal(user.Spec) + if err != nil { + return nil, err + } + finalAnnotations[util.LastAchievedSpec] = string(specBytes) + return finalAnnotations, nil +} diff --git a/controllers/operator/mongodbuser_controller_test.go b/controllers/operator/mongodbuser_controller_test.go new file mode 100644 index 000000000..bcc0b72cd --- /dev/null +++ b/controllers/operator/mongodbuser_controller_test.go @@ -0,0 +1,510 @@ +package operator + +import ( + "context" + "testing" + "time" + + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" + + "github.com/10gen/ops-manager-kubernetes/api/v1/status" + userv1 "github.com/10gen/ops-manager-kubernetes/api/v1/user" + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/authentication" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/mock" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/watch" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/event" +) + +func TestSettingUserStatus_ToPending_IsFilteredOut(t *testing.T) { + userInUpdatedPhase := &userv1.MongoDBUser{ObjectMeta: metav1.ObjectMeta{Name: "mms-user", Namespace: mock.TestNamespace}, Status: userv1.MongoDBUserStatus{Common: status.Common{Phase: status.PhaseUpdated}}} + userInPendingPhase := &userv1.MongoDBUser{ObjectMeta: metav1.ObjectMeta{Name: "mms-user", Namespace: mock.TestNamespace}, Status: userv1.MongoDBUserStatus{Common: status.Common{Phase: status.PhasePending}}} + + predicates := watch.PredicatesForUser() + updateEvent := event.UpdateEvent{ + ObjectOld: userInUpdatedPhase, + ObjectNew: userInPendingPhase, + } + assert.False(t, predicates.UpdateFunc(updateEvent), "changing phase from updated to pending should be filtered out") +} + +func TestUserIsAdded_ToAutomationConfig_OnSuccessfulReconciliation(t *testing.T) { + user := DefaultMongoDBUserBuilder().SetMongoDBResourceName("my-rs").Build() + reconciler, client := userReconcilerWithAuthMode(user, util.AutomationConfigScramSha256Option) + + // initialize resources required for the tests + _ = client.Update(context.TODO(), DefaultReplicaSetBuilder().EnableAuth().AgentAuthMode("SCRAM"). + SetName("my-rs").Build()) + createUserControllerConfigMap(client) + createPasswordSecret(client, user.Spec.PasswordSecretKeyRef, "password") + + actual, err := reconciler.Reconcile(context.TODO(), reconcile.Request{NamespacedName: kube.ObjectKey(user.Namespace, user.Name)}) + + expected := reconcile.Result{} + + assert.Nil(t, err, "there should be no error on successful reconciliation") + assert.Equal(t, expected, actual, "there should be a successful reconciliation if the password is a valid reference") + + connection := om.CurrMockedConnection + ac, _ := connection.ReadAutomationConfig() + + // the automation config should have been updated during reconciliation + assert.Len(t, ac.Auth.Users, 1, "the MongoDBUser should have been added to the AutomationConfig") + + _, createdUser := ac.Auth.GetUser("my-user", "admin") + assert.Equal(t, user.Spec.Username, createdUser.Username) + assert.Equal(t, user.Spec.Database, createdUser.Database) + assert.Equal(t, len(user.Spec.Roles), len(createdUser.Roles)) +} + +func TestUserIsUpdated_IfNonIdentifierFieldIsUpdated_OnSuccessfulReconciliation(t *testing.T) { + user := DefaultMongoDBUserBuilder().SetMongoDBResourceName("my-rs").Build() + reconciler, client := userReconcilerWithAuthMode(user, util.AutomationConfigScramSha256Option) + + // initialize resources required for the tests + _ = client.Update(context.TODO(), DefaultReplicaSetBuilder().SetName("my-rs").EnableAuth().AgentAuthMode("SCRAM"). + Build()) + createUserControllerConfigMap(client) + createPasswordSecret(client, user.Spec.PasswordSecretKeyRef, "password") + + actual, err := reconciler.Reconcile(context.TODO(), reconcile.Request{NamespacedName: kube.ObjectKey(user.Namespace, user.Name)}) + + expected := reconcile.Result{} + + assert.Nil(t, err, "there should be no error on successful reconciliation") + assert.Equal(t, expected, actual, "there should be a successful reconciliation if the password is a valid reference") + + // remove roles from the same user + updateUser(user, client, func(user *userv1.MongoDBUser) { + user.Spec.Roles = []userv1.Role{} + }) + + actual, err = reconciler.Reconcile(context.TODO(), reconcile.Request{NamespacedName: kube.ObjectKey(user.Namespace, user.Name)}) + + assert.Nil(t, err, "there should be no error on successful reconciliation") + assert.Equal(t, expected, actual, "there should be a successful reconciliation if the password is a valid reference") + + ac, _ := om.CurrMockedConnection.ReadAutomationConfig() + + assert.Len(t, ac.Auth.Users, 1, "we should still have a single MongoDBUser, no users should have been deleted") + _, updatedUser := ac.Auth.GetUser("my-user", "admin") + assert.Len(t, updatedUser.Roles, 0) +} + +func TestUserIsReplaced_IfIdentifierFieldsAreChanged_OnSuccessfulReconciliation(t *testing.T) { + user := DefaultMongoDBUserBuilder().SetMongoDBResourceName("my-rs").Build() + reconciler, client := userReconcilerWithAuthMode(user, util.AutomationConfigScramSha256Option) + + // initialize resources required for the tests + _ = client.Update(context.TODO(), DefaultReplicaSetBuilder().SetName("my-rs").EnableAuth().AgentAuthMode("SCRAM").Build()) + createUserControllerConfigMap(client) + createPasswordSecret(client, user.Spec.PasswordSecretKeyRef, "password") + + actual, err := reconciler.Reconcile(context.TODO(), reconcile.Request{NamespacedName: kube.ObjectKey(user.Namespace, user.Name)}) + + expected := reconcile.Result{} + + assert.Nil(t, err, "there should be no error on successful reconciliation") + assert.Equal(t, expected, actual, "there should be a successful reconciliation if the password is a valid reference") + + // change the username and database (these are the values used to identify a user) + updateUser(user, client, func(user *userv1.MongoDBUser) { + user.Spec.Username = "changed-name" + user.Spec.Database = "changed-db" + }) + + actual, err = reconciler.Reconcile(context.TODO(), reconcile.Request{NamespacedName: kube.ObjectKey(user.Namespace, user.Name)}) + + assert.Nil(t, err, "there should be no error on successful reconciliation") + assert.Equal(t, expected, actual, "there should be a successful reconciliation if the password is a valid reference") + + ac, _ := om.CurrMockedConnection.ReadAutomationConfig() + + assert.Len(t, ac.Auth.Users, 2, "we should have a new user with the updated fields and a nil value for the deleted user") + assert.False(t, ac.Auth.HasUser("my-user", "admin"), "the deleted user should no longer be present") + assert.True(t, containsNil(ac.Auth.Users), "the deleted user should have been assigned a nil value") + _, updatedUser := ac.Auth.GetUser("changed-name", "changed-db") + assert.Equal(t, "changed-name", updatedUser.Username, "new user name should be reflected") + assert.Equal(t, "changed-db", updatedUser.Database, "new database should be reflected") +} + +// updateUser applies and updates the changes to the user after getting the most recent version +// from the mocked client +func updateUser(user *userv1.MongoDBUser, client *mock.MockedClient, updateFunc func(*userv1.MongoDBUser)) { + _ = client.Get(context.TODO(), kube.ObjectKey(user.Namespace, user.Name), user) + updateFunc(user) + _ = client.Update(context.TODO(), user) +} + +func TestRetriesReconciliation_IfNoPasswordSecretExists(t *testing.T) { + user := DefaultMongoDBUserBuilder().SetMongoDBResourceName("my-rs").Build() + reconciler, client := defaultUserReconciler(user) + + // initialize resources required for the tests + _ = client.Update(context.TODO(), DefaultReplicaSetBuilder().SetName("my-rs").Build()) + createUserControllerConfigMap(client) + + // No password has been created + actual, err := reconciler.Reconcile(context.TODO(), reconcile.Request{NamespacedName: kube.ObjectKey(user.Namespace, user.Name)}) + + expected := reconcile.Result{RequeueAfter: time.Second * 10} + assert.Nil(t, err, "should be no error on retry") + assert.Equal(t, expected, actual, "the reconciliation should be retried as there is no password") + + connection := om.CurrMockedConnection + ac, _ := connection.ReadAutomationConfig() + + assert.Len(t, ac.Auth.Users, 0, "the MongoDBUser should not have been added to the AutomationConfig") +} + +func TestRetriesReconciliation_IfPasswordSecretExists_ButHasNoPassword(t *testing.T) { + user := DefaultMongoDBUserBuilder().SetMongoDBResourceName("my-rs").Build() + reconciler, client := defaultUserReconciler(user) + + // initialize resources required for the tests + _ = client.Update(context.TODO(), DefaultReplicaSetBuilder().SetName("my-rs").Build()) + createUserControllerConfigMap(client) + + // use the wrong key to store the password + createPasswordSecret(client, userv1.SecretKeyRef{Name: user.Spec.PasswordSecretKeyRef.Name, Key: "non-existent-key"}, "password") + + actual, err := reconciler.Reconcile(context.TODO(), reconcile.Request{NamespacedName: kube.ObjectKey(user.Namespace, user.Name)}) + + expected := reconcile.Result{RequeueAfter: time.Second * 10} + assert.Nil(t, err, "should be no error on retry") + assert.Equal(t, expected, actual, "the reconciliation should be retried as there is a secret, but the key contains no password") + + connection := om.CurrMockedConnection + ac, _ := connection.ReadAutomationConfig() + + assert.Len(t, ac.Auth.Users, 0, "the MongoDBUser should not have been added to the AutomationConfig") +} + +func TestX509User_DoesntRequirePassword(t *testing.T) { + user := DefaultMongoDBUserBuilder().SetDatabase(authentication.ExternalDB).Build() + reconciler, client := userReconcilerWithAuthMode(user, util.AutomationConfigX509Option) + + // initialize resources required for x590 tests + createMongoDBForUserWithAuth(client, *user, util.X509) + + createUserControllerConfigMap(client) + + // No password has been created + + // in order for x509 to be configurable, "util.AutomationConfigX509Option" needs to be enabled on the automation config + // pre-configure the connection + actual, err := reconciler.Reconcile(context.TODO(), reconcile.Request{NamespacedName: kube.ObjectKey(user.Namespace, user.Name)}) + + expected := reconcile.Result{} + + assert.Nil(t, err, "should be no error on successful reconciliation") + assert.Equal(t, expected, actual, "the reconciliation should be successful as x509 does not require a password") +} + +func AssertAuthModeTest(t *testing.T, mode string) { + user := DefaultMongoDBUserBuilder().SetMongoDBResourceName("my-rs").SetDatabase(authentication.ExternalDB).Build() + + reconciler, client := defaultUserReconciler(user) + err := client.Update(context.TODO(), DefaultReplicaSetBuilder().EnableAuth().SetAuthModes([]string{mode}).SetName("my-rs0").Build()) + assert.NoError(t, err) + createUserControllerConfigMap(client) + actual, err := reconciler.Reconcile(context.TODO(), reconcile.Request{NamespacedName: kube.ObjectKey(user.Namespace, user.Name)}) + + // reconciles if a $external user creation is attempted with no configured backends. + expected := reconcile.Result{Requeue: false, RequeueAfter: 10 * time.Second} + + assert.NoError(t, err) + assert.Equal(t, expected, actual) +} + +func TestExternalAuthUserReconciliation_RequiresExternalAuthConfigured(t *testing.T) { + AssertAuthModeTest(t, "LDAP") + AssertAuthModeTest(t, "X509") +} + +func TestScramShaUserReconciliation_CreatesAgentUsers(t *testing.T) { + user := DefaultMongoDBUserBuilder().SetMongoDBResourceName("my-rs").Build() + reconciler, client := userReconcilerWithAuthMode(user, util.AutomationConfigScramSha256Option) + + // initialize resources required for the tests + err := client.Update(context.TODO(), DefaultReplicaSetBuilder().AgentAuthMode("SCRAM").EnableAuth().SetName("my-rs").Build()) + assert.NoError(t, err) + createUserControllerConfigMap(client) + createPasswordSecret(client, user.Spec.PasswordSecretKeyRef, "password") + + actual, err := reconciler.Reconcile(context.TODO(), reconcile.Request{NamespacedName: kube.ObjectKey(user.Namespace, user.Name)}) + expected := reconcile.Result{} + + assert.NoError(t, err) + assert.Equal(t, expected, actual) + + ac, err := om.CurrMockedConnection.ReadAutomationConfig() + assert.NoError(t, err) + + assert.Len(t, ac.Auth.Users, 1, "users list should contain 1 user just added") +} + +func TestMultipleAuthMethod_CreateAgentUsers(t *testing.T) { + t.Run("When SCRAM and X509 auth modes are enabled, and agent mode is SCRAM, 3 users are created", func(t *testing.T) { + ac := BuildAuthenticationEnabledReplicaSet(t, util.AutomationConfigX509Option, 0, "SCRAM", []string{"SCRAM", "X509"}) + + assert.Equal(t, ac.Auth.AutoUser, "mms-automation-agent") + assert.Len(t, ac.Auth.Users, 1, "users list should contain a created user") + + expectedUsernames := []string{"mms-backup-agent", "mms-monitoring-agent", "my-user"} + for _, user := range ac.Auth.Users { + assert.True(t, stringutil.Contains(expectedUsernames, user.Username)) + } + }) + + t.Run("When X509 and SCRAM auth modes are enabled, and agent mode is X509, 1 user is created", func(t *testing.T) { + ac := BuildAuthenticationEnabledReplicaSet(t, util.AutomationConfigX509Option, 1, "X509", []string{"X509", "SCRAM"}) + assert.Equal(t, util.AutomationAgentName, ac.Auth.AutoUser) + assert.Len(t, ac.Auth.Users, 1, "users list should contain only 1 user") + assert.Equal(t, "$external", ac.Auth.Users[0].Database) + assert.Equal(t, "my-user", ac.Auth.Users[0].Username) + }) + + t.Run("When X509 and SCRAM auth modes are enabled, SCRAM is AgentAuthMode, 3 users are created", func(t *testing.T) { + ac := BuildAuthenticationEnabledReplicaSet(t, util.AutomationConfigX509Option, 0, "SCRAM", []string{"X509", "SCRAM"}) + + assert.Equal(t, ac.Auth.AutoUser, "mms-automation-agent") + assert.Len(t, ac.Auth.Users, 1, "users list should contain only one actual user") + assert.Equal(t, "my-user", ac.Auth.Users[0].Username) + }) + + t.Run("When X509 auth mode is enabled, 1 user will be created", func(t *testing.T) { + ac := BuildAuthenticationEnabledReplicaSet(t, util.AutomationConfigX509Option, 3, "X509", []string{"X509"}) + assert.Equal(t, util.AutomationAgentName, ac.Auth.AutoUser) + assert.Len(t, ac.Auth.Users, 1, "users list should contain only an actual user") + + expectedUsernames := []string{ + "my-user", + } + for _, user := range ac.Auth.Users { + assert.True(t, stringutil.Contains(expectedUsernames, user.Username)) + } + }) + + t.Run("When LDAP and X509 are enabled, 1 X509 user will be created", func(t *testing.T) { + ac := BuildAuthenticationEnabledReplicaSet(t, util.AutomationConfigLDAPOption, 3, "X509", []string{"LDAP", "X509"}) + assert.Equal(t, util.AutomationAgentName, ac.Auth.AutoUser) + assert.Len(t, ac.Auth.Users, 1, "users list should contain only an actual user") + + expectedUsernames := []string{ + "my-user", + } + for _, user := range ac.Auth.Users { + assert.True(t, stringutil.Contains(expectedUsernames, user.Username)) + } + }) + + t.Run("When LDAP is enabled, 1 SCRAM agent will be created", func(t *testing.T) { + ac := BuildAuthenticationEnabledReplicaSet(t, util.AutomationConfigLDAPOption, 0, "LDAP", []string{"LDAP"}) + assert.Equal(t, "mms-automation-agent", ac.Auth.AutoUser) + + assert.Len(t, ac.Auth.Users, 1, "users list should contain only 1 user") + assert.Equal(t, "my-user", ac.Auth.Users[0].Username) + assert.Equal(t, "$external", ac.Auth.Users[0].Database) + }) + +} + +// BuildAuthenticationEnabledReplicaSet returns a AutomationConfig after creating a Replica Set with a set of +// different Authentication values. It should be used to test different combination of authentication modes enabled +// and agent authentication modes. +func BuildAuthenticationEnabledReplicaSet(t *testing.T, automationConfigOption string, numAgents int, agentAuthMode string, authModes []string) *om.AutomationConfig { + user := DefaultMongoDBUserBuilder().SetMongoDBResourceName("my-rs").SetDatabase(authentication.ExternalDB).Build() + + reconciler, client := defaultUserReconciler(user) + reconciler.omConnectionFactory = func(ctx *om.OMContext) om.Connection { + connection := om.NewEmptyMockedOmConnectionWithAutomationConfigChanges(ctx, func(ac *om.AutomationConfig) { + ac.Auth.DeploymentAuthMechanisms = append(ac.Auth.DeploymentAuthMechanisms, automationConfigOption) + }) + return connection + } + + builder := DefaultReplicaSetBuilder().EnableAuth().SetAuthModes(authModes).SetName("my-rs") + if agentAuthMode != "" { + builder.AgentAuthMode(agentAuthMode) + } + + err := client.Update(context.TODO(), builder.Build()) + assert.NoError(t, err) + createUserControllerConfigMap(client) + _, err = reconciler.Reconcile(context.TODO(), reconcile.Request{NamespacedName: kube.ObjectKey(user.Namespace, user.Name)}) + assert.NoError(t, err) + + ac, err := om.CurrMockedConnection.ReadAutomationConfig() + assert.NoError(t, err) + + return ac +} + +// createUserControllerConfigMap creates a configmap with credentials present +func createUserControllerConfigMap(client *mock.MockedClient) { + _ = client.Update(context.TODO(), &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: om.TestGroupName, Namespace: mock.TestNamespace}, + Data: map[string]string{ + util.OmBaseUrl: om.TestURL, + util.OmOrgId: om.TestOrgID, + util.OmProjectName: om.TestGroupName, + util.OmCredentials: mock.TestCredentialsSecretName, + }, + }) +} + +func containsNil(users []*om.MongoDBUser) bool { + for _, user := range users { + if user == nil { + return true + } + } + return false +} +func createPasswordSecret(client *mock.MockedClient, secretRef userv1.SecretKeyRef, password string) { + _ = client.Update(context.TODO(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: secretRef.Name, Namespace: mock.TestNamespace}, + Data: map[string][]byte{ + secretRef.Key: []byte(password), + }, + }) +} + +func createMongoDBForUserWithAuth(client *mock.MockedClient, user userv1.MongoDBUser, authModes ...string) { + mdbBuilder := DefaultReplicaSetBuilder().SetName(user.Spec.MongoDBResourceRef.Name) + + _ = client.Update(context.TODO(), mdbBuilder.SetAuthModes(authModes).Build()) +} + +// defaultUserReconciler is the user reconciler used in unit test. It "adds" necessary +// additional K8s objects (st, connection config map and secrets) necessary for reconciliation +func defaultUserReconciler(user *userv1.MongoDBUser) (*MongoDBUserReconciler, *mock.MockedClient) { + manager := mock.NewManager(user) + manager.Client.AddDefaultMdbConfigResources() + memberClusterMap := getFakeMultiClusterMap() + return newMongoDBUserReconciler(manager, om.NewEmptyMockedOmConnection, memberClusterMap), manager.Client +} + +func userReconcilerWithAuthMode(user *userv1.MongoDBUser, authMode string) (*MongoDBUserReconciler, *mock.MockedClient) { + manager := mock.NewManager(user) + manager.Client.AddDefaultMdbConfigResources() + memberClusterMap := getFakeMultiClusterMap() + reconciler := newMongoDBUserReconciler(manager, func(context *om.OMContext) om.Connection { + connection := om.NewEmptyMockedOmConnectionWithAutomationConfigChanges(context, func(ac *om.AutomationConfig) { + ac.Auth.DeploymentAuthMechanisms = append(ac.Auth.DeploymentAuthMechanisms, authMode) + // Enabling auth as it's required to be enabled for the user controller to proceed + ac.Auth.Disabled = false + }) + return connection + }, memberClusterMap) + return reconciler, manager.Client +} + +type MongoDBUserBuilder struct { + project string + passwordRef userv1.SecretKeyRef + roles []userv1.Role + username string + database string + resourceName string + mongodbResourceName string + namespace string +} + +func (b *MongoDBUserBuilder) SetPasswordRef(secretName, key string) *MongoDBUserBuilder { + b.passwordRef = userv1.SecretKeyRef{Name: secretName, Key: key} + return b +} + +func (b *MongoDBUserBuilder) SetMongoDBResourceName(name string) *MongoDBUserBuilder { + b.mongodbResourceName = name + return b +} + +func (b *MongoDBUserBuilder) SetUsername(username string) *MongoDBUserBuilder { + b.username = username + return b +} + +func (b *MongoDBUserBuilder) SetNamespace(namespace string) *MongoDBUserBuilder { + b.namespace = namespace + return b +} + +func (b *MongoDBUserBuilder) SetDatabase(db string) *MongoDBUserBuilder { + b.database = db + return b +} + +func (b *MongoDBUserBuilder) SetProject(project string) *MongoDBUserBuilder { + b.project = project + return b +} + +func (b *MongoDBUserBuilder) SetResourceName(resourceName string) *MongoDBUserBuilder { + b.resourceName = resourceName + return b +} + +func (b *MongoDBUserBuilder) SetRoles(roles []userv1.Role) *MongoDBUserBuilder { + b.roles = roles + return b +} + +func DefaultMongoDBUserBuilder() *MongoDBUserBuilder { + return &MongoDBUserBuilder{ + roles: []userv1.Role{{ + RoleName: "role-1", + Database: "admin", + }, { + RoleName: "role-2", + Database: "admin", + }, { + RoleName: "role-3", + Database: "admin", + }}, + project: mock.TestProjectConfigMapName, + passwordRef: userv1.SecretKeyRef{ + Name: "password-secret", + Key: "password", + }, + username: "my-user", + database: "admin", + mongodbResourceName: mock.TestMongoDBName, + namespace: mock.TestNamespace, + } +} + +func (b *MongoDBUserBuilder) Build() *userv1.MongoDBUser { + if b.roles == nil { + b.roles = make([]userv1.Role, 0) + } + if b.resourceName == "" { + b.resourceName = b.username + } + + return &userv1.MongoDBUser{ + ObjectMeta: metav1.ObjectMeta{ + Name: b.resourceName, + Namespace: b.namespace, + }, + Spec: userv1.MongoDBUserSpec{ + Roles: b.roles, + PasswordSecretKeyRef: b.passwordRef, + Username: b.username, + Database: b.database, + MongoDBResourceRef: userv1.MongoDBResourceRef{ + Name: b.mongodbResourceName, + }, + }, + } +} diff --git a/controllers/operator/mongodbuser_eventhandler.go b/controllers/operator/mongodbuser_eventhandler.go new file mode 100644 index 000000000..95fec2b9e --- /dev/null +++ b/controllers/operator/mongodbuser_eventhandler.go @@ -0,0 +1,26 @@ +package operator + +import ( + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "go.uber.org/zap" + "k8s.io/client-go/util/workqueue" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" +) + +type MongoDBUserEventHandler struct { + *handler.EnqueueRequestForObject + reconciler interface { + delete(obj interface{}, log *zap.SugaredLogger) error + } +} + +func (eh *MongoDBUserEventHandler) Delete(e event.DeleteEvent, _ workqueue.RateLimitingInterface) { + zap.S().Infow("Cleaning up MongoDBUser resource", "resource", e.Object) + logger := zap.S().With("resource", kube.ObjectKey(e.Object.GetNamespace(), e.Object.GetName())) + if err := eh.reconciler.delete(e.Object, logger); err != nil { + logger.Errorf("MongoDBUser resource removed from Kubernetes, but failed to clean some state in Ops Manager: %s", err) + return + } + logger.Info("Removed MongoDBUser resource from Kubernetes and Ops Manager") +} diff --git a/controllers/operator/namespace_watched.go b/controllers/operator/namespace_watched.go new file mode 100644 index 000000000..cb4917b9d --- /dev/null +++ b/controllers/operator/namespace_watched.go @@ -0,0 +1,55 @@ +package operator + +import ( + "os" + "strings" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" +) + +// GetWatchedNamespace returns a namespace or namespaces to watch. +// +// If `WATCH_NAMESPACE` has not been set, is an empty string or is a star ("*"), it watches all namespaces. +// If `WATCH_NAMESPACE` is set, it watches over that namespace, unless there are commas in there, in which +// the namespaces to watch will be a comma-separated list. +// +// If `WATCH_NAMESPACE` is '*' it will return []string{""}, which means all namespaces will be watched. +func GetWatchedNamespace() []string { + watchNamespace, nsSpecified := os.LookupEnv(util.WatchNamespace) + + // If WatchNamespace is not specified - we assume the Operator is watching all namespaces. + // In contrast to the common way to configure cluster-wide operators we additionally support '*' + // see: https://sdk.operatorframework.io/docs/building-operators/golang/operator-scope/#configuring-watch-namespaces-dynamically + if !nsSpecified || len(watchNamespace) == 0 || strings.TrimSpace(watchNamespace) == "" || strings.TrimSpace(watchNamespace) == "*" { + return []string{""} + } + + if strings.Contains(watchNamespace, ",") { + namespaceSplit := strings.Split(watchNamespace, ",") + namespaceList := []string{} + for i := range namespaceSplit { + namespace := strings.TrimSpace(namespaceSplit[i]) + if namespace != "" { + namespaceList = append(namespaceList, namespace) + } + } + + if stringutil.Contains(namespaceList, "*") { + // If `WATCH_NAMESPACE` contains a single *, then we return a list + // of "" as a defensive measure, to avoid cases where + // WATCH_NAMESPACE could be "*,another-namespace" which could at + // some point make the Operator traverse "another-namespace" twice. + return []string{""} + } + + if len(namespaceList) > 0 { + return namespaceList + } + + return []string{env.ReadOrDefault(util.CurrentNamespace, "")} + } + + return []string{watchNamespace} +} diff --git a/controllers/operator/namespace_watched_test.go b/controllers/operator/namespace_watched_test.go new file mode 100644 index 000000000..ccaed33e6 --- /dev/null +++ b/controllers/operator/namespace_watched_test.go @@ -0,0 +1,35 @@ +package operator + +import ( + "os" + "testing" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/stretchr/testify/assert" +) + +func TestGetWatchedNamespace(t *testing.T) { + t.Setenv(util.WatchNamespace, "one-namespace") + assert.Equal(t, []string{"one-namespace"}, GetWatchedNamespace()) + + t.Setenv(util.WatchNamespace, "one-namespace, two-namespace,three-namespace") + assert.Equal(t, []string{"one-namespace", "two-namespace", "three-namespace"}, GetWatchedNamespace()) + + t.Setenv(util.WatchNamespace, "") + assert.Equal(t, []string{""}, GetWatchedNamespace()) + + t.Setenv(util.WatchNamespace, ",") + assert.Equal(t, []string{OperatorNamespace}, GetWatchedNamespace()) + + t.Setenv(util.WatchNamespace, ",one-namespace") + assert.Equal(t, []string{"one-namespace"}, GetWatchedNamespace()) + + t.Setenv(util.WatchNamespace, "*") + assert.Equal(t, []string{""}, GetWatchedNamespace()) + + t.Setenv(util.WatchNamespace, "*,hi") + assert.Equal(t, []string{""}, GetWatchedNamespace()) + + os.Unsetenv(util.WatchNamespace) + assert.Equal(t, []string{""}, GetWatchedNamespace()) +} diff --git a/controllers/operator/operator_configuration.go b/controllers/operator/operator_configuration.go new file mode 100644 index 000000000..18ab1a9c6 --- /dev/null +++ b/controllers/operator/operator_configuration.go @@ -0,0 +1,11 @@ +package operator + +import ( + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" +) + +// operatorNamespace returns the current namespace where the Operator is deployed +func operatorNamespace() string { + return env.ReadOrPanic(util.CurrentNamespace) +} diff --git a/controllers/operator/pem/pem_collection.go b/controllers/operator/pem/pem_collection.go new file mode 100644 index 000000000..1e221f126 --- /dev/null +++ b/controllers/operator/pem/pem_collection.go @@ -0,0 +1,182 @@ +package pem + +import ( + "crypto/sha256" + "crypto/x509" + "encoding/base32" + "encoding/json" + "encoding/pem" + "strings" + + "golang.org/x/xerrors" +) + +type Collection struct { + PemFiles map[string]File +} + +// GetHash returns a cryptographically hashed representation of the collection +// of PEM files. +func (p Collection) GetHash() (string, error) { + // this relies on the implementation detail that json.Marshal sorts the keys + // in a map when performing the serialisation, thus resulting in a + // deterministic representation of the struct + jsonBytes, err := json.Marshal(p.PemFiles) + if err != nil { + // this should never happen + return "", xerrors.Errorf("could not marshal PEM files to JSON: %w", err) + } + hashBytes := sha256.Sum256(jsonBytes) + + // base32 encoding without padding (i.e. no '=' character) is used as this + // guarantees a strictly alphanumeric output. Since the result is a hash, and + // thus needs not be reversed, removing the padding is not an issue. + return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(hashBytes[:]), nil +} + +// NewCollection creates a new Pem Collection with an initialized empty map. +func NewCollection() *Collection { + return &Collection{ + PemFiles: make(map[string]File), + } +} + +// AddPrivateKey ensures a Pem File exists for the given hostname and key. +func (p Collection) AddPrivateKey(hostname, key string) { + if key == "" { + return + } + pem, ok := p.PemFiles[hostname] + if !ok { + pem = File{PrivateKey: key} + } else { + pem.PrivateKey = key + } + p.PemFiles[hostname] = pem +} + +// AddCertificate ensures a Pem File is added for the given hostname and cert. +func (p Collection) AddCertificate(hostname, cert string) { + if cert == "" { + return + } + pem, ok := p.PemFiles[hostname] + if !ok { + pem = File{Certificate: cert} + } else { + pem.Certificate = cert + } + p.PemFiles[hostname] = pem +} + +// MergeEntry merges a given PEM file into the collection of PEM files. If a +// file with the same hostname exists in the collection, then existing +// components will not be overridden. +func (p Collection) MergeEntry(hostname string, pem File) { + existingPem := p.PemFiles[hostname] + if existingPem.PrivateKey == "" { + p.AddPrivateKey(hostname, pem.PrivateKey) + } + if existingPem.Certificate == "" { + p.AddCertificate(hostname, pem.Certificate) + } +} + +// Merge combines all Pem Files into a map[string]string. +func (p *Collection) Merge() map[string]string { + result := make(map[string]string) + + for k, v := range p.PemFiles { + result[k+"-pem"] = v.String() + } + + return result +} + +// MergeWith merges the provided entry into this Collection. +func (p *Collection) MergeWith(data map[string][]byte) map[string]string { + for k, v := range data { + hostname := strings.TrimSuffix(k, "-pem") + p.MergeEntry(hostname, NewFileFrom(string(v))) + } + + return p.Merge() +} + +func (pf File) ParseCertificate() ([]*x509.Certificate, error) { + var certs []*x509.Certificate + for block, rest := pem.Decode([]byte(pf.Certificate)); block != nil; block, rest = pem.Decode(rest) { + if block == nil { + return []*x509.Certificate{}, xerrors.Errorf("failed to parse certificate PEM, please ensure validity of the file") + } + switch block.Type { + case "CERTIFICATE": + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return []*x509.Certificate{}, err + } + certs = append(certs, cert) + default: + return []*x509.Certificate{}, xerrors.Errorf("failed to parse certificate PEM, please ensure validity of the file") + } + + } + return certs, nil +} + +type File struct { + PrivateKey string `json:"privateKey"` + Certificate string `json:"certificate"` +} + +func NewFileFrom(data string) File { + parts := separatePemFile(data) + privateKey := "" + certificate := "" + + for _, el := range parts { + if strings.Contains(el, "BEGIN CERTIFICATE") { + certificate += el + } else if strings.Contains(el, "PRIVATE KEY") { + privateKey = el + } + } + + return File{ + PrivateKey: privateKey, + Certificate: certificate, + } +} + +func NewFileFromData(data []byte) File { + return NewFileFrom(string(data)) +} + +func (p *File) IsValid() bool { + return p.PrivateKey != "" +} + +func (p *File) IsComplete() bool { + return p.IsValid() && p.Certificate != "" +} + +func (p *File) String() string { + return p.Certificate + p.PrivateKey +} + +func separatePemFile(data string) []string { + parts := strings.Split(data, "\n") + certificates := make([]string, 0) + certificatePart := "" + + for _, el := range parts { + if strings.HasPrefix(el, "-----END") { + certificates = append(certificates, certificatePart+el+"\n") + certificatePart = "" + continue + } + certificatePart += el + "\n" + } + + return certificates +} diff --git a/controllers/operator/pem/pem_collection_test.go b/controllers/operator/pem/pem_collection_test.go new file mode 100644 index 000000000..953c0dc4c --- /dev/null +++ b/controllers/operator/pem/pem_collection_test.go @@ -0,0 +1,79 @@ +package pem + +import ( + "errors" + "testing" +) + +func TestParseCertificate(t *testing.T) { + tests := []struct { + inp File + err error + }{ + { + inp: File{ + PrivateKey: "mykey", + Certificate: "mycert", + }, + err: errors.New("failed to parse certificate PEM, please ensure validity of the file"), + }, + { + inp: File{ + PrivateKey: "mykey", + Certificate: `-----BEGIN CERTIFICATE----- +MIIB/jCCAWICCQDscdUxw16XFDAJBgcqhkjOPQQBMEUxCzAJBgNVBAYTAkFVMRMw +EQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0 +eSBMdGQwHhcNMTIxMTE0MTI0MDQ4WhcNMTUxMTE0MTI0MDQ4WjBFMQswCQYDVQQG +EwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50ZXJuZXQgV2lk +Z2l0cyBQdHkgTHRkMIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQBY9+my9OoeSUR +lDQdV/x8LsOuLilthhiS1Tz4aGDHIPwC1mlvnf7fg5lecYpMCrLLhauAc1UJXcgl +01xoLuzgtAEAgv2P/jgytzRSpUYvgLBt1UA0leLYBy6mQQbrNEuqT3INapKIcUv8 +XxYP0xMEUksLPq6Ca+CRSqTtrd/23uTnapkwCQYHKoZIzj0EAQOBigAwgYYCQXJo +A7Sl2nLVf+4Iu/tAX/IF4MavARKC4PPHK3zfuGfPR3oCCcsAoz3kAzOeijvd0iXb +H5jBImIxPL4WxQNiBTexAkF8D1EtpYuWdlVQ80/h/f4pBcGiXPqX5h2PQSQY7hP1 ++jwM1FGS4fREIOvlBYr/SzzQRtwrvrzGYxDEDbsC0ZGRnA== +-----END CERTIFICATE----- +`, + }, + err: nil, + }, + { + inp: File{ + PrivateKey: "mykey", + Certificate: `-----BEGIN CERTIFICATE----- +MIIB/jCCAWICCQDscdUxw16XFDAJBgcqhkjOPQQBMEUxCzAJBgNVBAYTAkFVMRMw +EQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0 +eSBMdGQwHhcNMTIxMTE0MTI0MDQ4WhcNMTUxMTE0MTI0MDQ4WjBFMQswCQYDVQQG +EwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50ZXJuZXQgV2lk +Z2l0cyBQdHkgTHRkMIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQBY9+my9OoeSUR +lDQdV/x8LsOuLilthhiS1Tz4aGDHIPwC1mlvnf7fg5lecYpMCrLLhauAc1UJXcgl +01xoLuzgtAEAgv2P/jgytzRSpUYvgLBt1UA0leLYBy6mQQbrNEuqT3INapKIcUv8 +XxYP0xMEUksLPq6Ca+CRSqTtrd/23uTnapkwCQYHKoZIzj0EAQOBigAwgYYCQXJo +A7Sl2nLVf+4Iu/tAX/IF4MavARKC4PPHK3zfuGfPR3oCCcsAoz3kAzOeijvd0iXb +H5jBImIxPL4WxQNiBTexAkF8D1EtpYuWdlVQ80/h/f4pBcGiXPqX5h2PQSQY7hP1 ++jwM1FGS4fREIOvlBYr/SzzQRtwrvrzGYxDEDbsC0ZGRnA== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIB/jCCAWICCQDscdUxw16XFDAJBgcqhkjOPQQBMEUxCzAJBgNVBAYTAkFVMRMw +EQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0 +eSBMdGQwHhcNMTIxMTE0MTI0MDQ4WhcNMTUxMTE0MTI0MDQ4WjBFMQswCQYDVQQG +EwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50ZXJuZXQgV2lk +Z2l0cyBQdHkgTHRkMIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQBY9+my9OoeSUR +lDQdV/x8LsOuLilthhiS1Tz4aGDHIPwC1mlvnf7fg5lecYpMCrLLhauAc1UJXcgl +01xoLuzgtAEAgv2P/jgytzRSpUYvgLBt1UA0leLYBy6mQQbrNEuqT3INapKIcUv8 +XxYP0xMEUksLPq6Ca+CRSqTtrd/23uTnapkwCQYHKoZIzj0EAQOBigAwgYYCQXJo +A7Sl2nLVf+4Iu/tAX/IF4MavARKC4PPHK3zfuGfPR3oCCcsAoz3kAzOeijvd0iXb +H5jBImIxPL4WxQNiBTexAkF8D1EtpYuWdlVQ80/h/f4pBcGiXPqX5h2PQSQY7hP1 ++jwM1FGS4fREIOvlBYr/SzzQRtwrvrzGYxDEDbsC0ZGRnA== +-----END CERTIFICATE----- +`, + }, + err: nil, + }, + } + + for _, tt := range tests { + _, err := tt.inp.ParseCertificate() + errors.Is(err, tt.err) + } +} diff --git a/controllers/operator/pem/secret.go b/controllers/operator/pem/secret.go new file mode 100644 index 000000000..dff9d72d0 --- /dev/null +++ b/controllers/operator/pem/secret.go @@ -0,0 +1,53 @@ +package pem + +import ( + "fmt" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/secrets" + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "github.com/10gen/ops-manager-kubernetes/pkg/vault" + "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" +) + +// ReadHashFromSecret reads the existing Pem from +// the secret that stores this StatefulSet's Pem collection. +func ReadHashFromSecret(secretClient secrets.SecretClient, namespace, name string, basePath string, log *zap.SugaredLogger) string { + var secretData map[string]string + var err error + if vault.IsVaultSecretBackend() { + path := fmt.Sprintf("%s/%s/%s", basePath, namespace, name) + secretData, err = secretClient.VaultClient.ReadSecretString(path) + if err != nil { + log.Debugf("tls secret %s doesn't exist yet, unable to compute hash of pem", name) + return "" + } + } else { + s, err := secretClient.KubeClient.GetSecret(kube.ObjectKey(namespace, name)) + if err != nil { + log.Debugf("tls secret %s doesn't exist yet, unable to compute hash of pem", name) + return "" + } + + if s.Type != corev1.SecretTypeTLS { + log.Debugf("tls secret %s is not of type corev1.SecretTypeTLS; we will not use hash as key name", name) + return "" + } + + secretData = secrets.DataToStringData(s.Data) + } + return ReadHashFromData(secretData, log) +} + +func ReadHashFromData(secretData map[string]string, log *zap.SugaredLogger) string { + pemCollection := NewCollection() + for k, v := range secretData { + pemCollection.MergeEntry(k, NewFileFrom(v)) + } + pemHash, err := pemCollection.GetHash() + if err != nil { + log.Errorf("error computing pem hash: %s", err) + return "" + } + return pemHash +} diff --git a/controllers/operator/pem_test.go b/controllers/operator/pem_test.go new file mode 100644 index 000000000..75a7f439e --- /dev/null +++ b/controllers/operator/pem_test.go @@ -0,0 +1,176 @@ +package operator + +import ( + "testing" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/mock" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/secrets" + "go.uber.org/zap" + "golang.org/x/xerrors" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/pem" + + "github.com/stretchr/testify/assert" +) + +func TestGetPEMHashIsDeterministic(t *testing.T) { + pemCollection := pem.Collection{ + PemFiles: map[string]pem.File{ + "myhostname1": { + PrivateKey: "mykey", + Certificate: "mycert", + }, + "myhostname2": { + PrivateKey: "mykey", + Certificate: "mycert", + }, + }, + } + firstHash, err := pemCollection.GetHash() + assert.NoError(t, err) + + // modify the PEM collection and check the hash is different + pemCollection.PemFiles["myhostname3"] = pem.File{ + PrivateKey: "thirdey", + Certificate: "thirdcert", + } + secondHash, err := pemCollection.GetHash() + assert.NoError(t, err) + assert.NotEqual(t, firstHash, secondHash) + + // revert the changes to the PEM collection and check the hash is the same + delete(pemCollection.PemFiles, "myhostname3") + thirdHash, err := pemCollection.GetHash() + assert.NoError(t, err) + assert.Equal(t, firstHash, thirdHash) +} + +func TestMergeEntryOverwritesOldSecret(t *testing.T) { + p := pem.Collection{ + PemFiles: map[string]pem.File{ + "myhostname": { + PrivateKey: "mykey", + Certificate: "mycert", + }, + }, + } + + secretData := pem.File{ + PrivateKey: "oldkey", + Certificate: "oldcert", + } + + p.MergeEntry("myhostname", secretData) + assert.Equal(t, "mykey", p.PemFiles["myhostname"].PrivateKey) + assert.Equal(t, "mycert", p.PemFiles["myhostname"].Certificate) +} + +func TestMergeEntryOnlyCertificate(t *testing.T) { + p := pem.Collection{ + PemFiles: map[string]pem.File{ + "myhostname": { + PrivateKey: "mykey", + }, + }, + } + + secretData := pem.File{ + PrivateKey: "oldkey", + Certificate: "oldcert", + } + + p.MergeEntry("myhostname", secretData) + assert.Equal(t, "mykey", p.PemFiles["myhostname"].PrivateKey) + assert.Equal(t, "oldcert", p.PemFiles["myhostname"].Certificate) +} + +func TestMergeEntryPreservesOldSecret(t *testing.T) { + p := pem.Collection{ + PemFiles: map[string]pem.File{ + "myexistinghostname": { + PrivateKey: "mykey", + Certificate: "mycert", + }, + }, + } + + secretData := pem.File{ + PrivateKey: "oldkey", + Certificate: "oldcert", + } + + p.MergeEntry("myhostname", secretData) + assert.Equal(t, "oldkey", p.PemFiles["myhostname"].PrivateKey) + assert.Equal(t, "oldcert", p.PemFiles["myhostname"].Certificate) + assert.Equal(t, "mykey", p.PemFiles["myexistinghostname"].PrivateKey) + assert.Equal(t, "mycert", p.PemFiles["myexistinghostname"].Certificate) +} + +type mockSecretGetter struct { + secret *corev1.Secret +} + +func (m mockSecretGetter) GetSecret(_ client.ObjectKey) (corev1.Secret, error) { + if m.secret == nil { + return corev1.Secret{}, xerrors.Errorf("not found") + } + return *m.secret, nil +} + +func (m mockSecretGetter) CreateSecret(s corev1.Secret) error { + return nil +} + +func (m mockSecretGetter) UpdateSecret(s corev1.Secret) error { + return nil +} + +func (m mockSecretGetter) DeleteSecret(nsName types.NamespacedName) error { + return nil +} + +func TestReadPemHashFromSecret(t *testing.T) { + name := "res-name" + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: name + "-cert", Namespace: mock.TestNamespace}, + Data: map[string][]byte{"hello": []byte("world")}, + Type: corev1.SecretTypeTLS, + } + + assert.Empty(t, pem.ReadHashFromSecret(secrets.SecretClient{ + VaultClient: nil, + KubeClient: mockSecretGetter{}, + }, mock.TestNamespace, name, "", zap.S()), "secret does not exist so pem hash should be empty") + + hash := pem.ReadHashFromSecret(secrets.SecretClient{ + VaultClient: nil, + KubeClient: mockSecretGetter{secret: secret}, + }, mock.TestNamespace, name, "", zap.S()) + + hash2 := pem.ReadHashFromSecret(secrets.SecretClient{ + VaultClient: nil, + KubeClient: mockSecretGetter{secret: secret}, + }, mock.TestNamespace, name, "", zap.S()) + + assert.NotEmpty(t, hash, "pem hash should be read from the secret") + assert.Equal(t, hash, hash2, "hash creation should be idempotent") +} + +func TestReadPemHashFromSecretOpaqueType(t *testing.T) { + + name := "res-name" + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: name + "-cert", Namespace: mock.TestNamespace}, + Data: map[string][]byte{"hello": []byte("world")}, + Type: corev1.SecretTypeOpaque, + } + + assert.Empty(t, pem.ReadHashFromSecret(secrets.SecretClient{ + VaultClient: nil, + KubeClient: mockSecretGetter{secret: secret}, + }, mock.TestNamespace, name, "", zap.S()), "if secret type is not TLS the empty string should be returned") +} diff --git a/controllers/operator/project/credentials.go b/controllers/operator/project/credentials.go new file mode 100644 index 000000000..b22c98f49 --- /dev/null +++ b/controllers/operator/project/credentials.go @@ -0,0 +1,57 @@ +package project + +import ( + "go.uber.org/zap" + "golang.org/x/xerrors" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/secrets" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/vault" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ReadCredentials reads the Secret containing the credentials to authenticate in Ops Manager and creates a matching 'Credentials' object +func ReadCredentials(secretClient secrets.SecretClient, credentialsSecret client.ObjectKey, log *zap.SugaredLogger) (mdbv1.Credentials, error) { + var operatorSecretPath string + if vault.IsVaultSecretBackend() { + operatorSecretPath = secretClient.VaultClient.OperatorSecretPath() + } + secret, err := secretClient.ReadSecret(credentialsSecret, operatorSecretPath) + if err != nil { + return mdbv1.Credentials{}, err + } + oldSecretEntries, user, publicAPIKey := secretContainsPairOfKeys(secret, util.OldOmUser, util.OldOmPublicApiKey) + + newSecretEntries, publicKey, privateKey := secretContainsPairOfKeys(secret, util.OmPublicApiKey, util.OmPrivateKey) + + if !(oldSecretEntries || newSecretEntries) { + return mdbv1.Credentials{}, xerrors.Errorf("secret %s does not contain the required entries. It should contain either %s and %s, or %s and %s", credentialsSecret, util.OldOmUser, util.OldOmPublicApiKey, util.OmPublicApiKey, util.OmPrivateKey) + } + + if oldSecretEntries { + log.Infof("Usage of old entries for the credentials secret (\"%s\" and \"%s\") is deprecated, prefer using \"%s\" and \"%s\"", util.OldOmUser, util.OldOmPublicApiKey, util.OmPublicApiKey, util.OmPrivateKey) + return mdbv1.Credentials{ + PublicAPIKey: user, + PrivateAPIKey: publicAPIKey, + }, nil + } + + return mdbv1.Credentials{ + PublicAPIKey: publicKey, + PrivateAPIKey: privateKey, + }, nil + +} + +func secretContainsPairOfKeys(secret map[string]string, key1 string, key2 string) (bool, string, string) { + val1, ok := secret[key1] + if !ok { + return false, "", "" + } + val2, ok := secret[key2] + if !ok { + return false, "", "" + } + return true, val1, val2 +} diff --git a/controllers/operator/project/project.go b/controllers/operator/project/project.go new file mode 100644 index 000000000..419311dfe --- /dev/null +++ b/controllers/operator/project/project.go @@ -0,0 +1,223 @@ +package project + +import ( + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/configmap" + "golang.org/x/xerrors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/10gen/ops-manager-kubernetes/controllers/om/apierror" + "github.com/10gen/ops-manager-kubernetes/controllers/operator/secrets" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "go.uber.org/zap" +) + +// Reader returns the name of a ConfigMap which contains Ops Manager project details. +// and the name of a secret containing project credentials. +type Reader interface { + metav1.Object + GetProjectConfigMapName() string + GetProjectConfigMapNamespace() string + GetCredentialsSecretName() string + GetCredentialsSecretNamespace() string +} + +// ReadConfigAndCredentials returns the ProjectConfig and Credentials for a given resource which are +// used to communicate with Ops Manager. +func ReadConfigAndCredentials(cmGetter configmap.Getter, secretGetter secrets.SecretClient, reader Reader, log *zap.SugaredLogger) (mdbv1.ProjectConfig, mdbv1.Credentials, error) { + projectConfig, err := ReadProjectConfig(cmGetter, kube.ObjectKey(reader.GetProjectConfigMapNamespace(), reader.GetProjectConfigMapName()), reader.GetName()) + if err != nil { + return mdbv1.ProjectConfig{}, mdbv1.Credentials{}, xerrors.Errorf("error reading project %w", err) + } + credsConfig, err := ReadCredentials(secretGetter, kube.ObjectKey(reader.GetCredentialsSecretNamespace(), reader.GetCredentialsSecretName()), log) + if err != nil { + return mdbv1.ProjectConfig{}, mdbv1.Credentials{}, xerrors.Errorf("error reading Credentials secret: %w", err) + } + return projectConfig, credsConfig, nil +} + +/* +Communication with groups is tricky. +In connection ConfigMap user must provide the project name and the id of organization. +The only way to find out if the project exists already is to check if its +organization name is the same as the projects one and that's what Operator is doing. So if ConfigMap specifies the +project with name "A" and no org id and there is already project in Ops manager named "A" in organization with name"B" +then Operator won't find it as the names don't match. + +Note, that the method is performed holding the "groupName+orgId" mutex which allows to avoid race conditions and avoid +duplicated groups/organizations creation. So if for example the standalone and the replica set which reference the same +configMap are created in parallel - this function will be invoked sequentially and the second caller will see the group +created on the first call +*/ +func ReadOrCreateProject(config mdbv1.ProjectConfig, credentials mdbv1.Credentials, connectionFactory om.ConnectionFactory, log *zap.SugaredLogger) (*om.Project, om.Connection, error) { + projectName := config.ProjectName + mutex := om.GetMutex(projectName, config.OrgID) + mutex.Lock() + defer mutex.Unlock() + + log = log.With("project", projectName) + + // we need to create a temporary connection object without group id + omContext := om.OMContext{ + GroupID: "", + GroupName: projectName, + OrgID: config.OrgID, + BaseURL: config.BaseURL, + PublicKey: credentials.PublicAPIKey, + PrivateKey: credentials.PrivateAPIKey, + + // The OM Client expects the inverse of "Require valid cert" because in Go + // The "zero" value of bool is "False", hence this default. + AllowInvalidSSLCertificate: !config.SSLRequireValidMMSServerCertificates, + + // The CA certificate passed to the OM client needs to be a actual certificate, + // and not a location in disk, because each "project" will have its own CA cert. + CACertificate: config.SSLMMSCAConfigMapContents, + } + + conn := connectionFactory(&omContext) + + org, err := findOrganization(config.OrgID, projectName, conn, log) + if err != nil { + return nil, nil, err + } + + var project *om.Project + if org != nil { + project, err = findProject(projectName, org, conn, log) + + if err != nil { + return nil, nil, err + } + } + + if project == nil { + project, err = tryCreateProject(org, projectName, config.OrgID, conn, log) + if err != nil { + return nil, nil, err + } + } + + conn.ConfigureProject(project) + + return project, conn, nil +} + +func findOrganization(orgID string, projectName string, conn om.Connection, log *zap.SugaredLogger) (*om.Organization, error) { + if orgID == "" { + // Note: this org_id = "" has to be explicitly set by the customer. + // If org id is not specified - then the contract is that the organization for the project must have the same + // name as project has (as it was created automatically for the project), so we need to find relevant organization + log.Debugf("Organization id is not specified - trying to find the organization with name \"%s\"", projectName) + var err error + if orgID, err = findOrganizationByName(conn, projectName, log); err != nil { + return nil, err + } + if orgID == "" { + log.Debugf("Organization \"%s\" not found", projectName) + return nil, nil + } + } + + organization, err := conn.ReadOrganization(orgID) + if err != nil { + return nil, xerrors.Errorf("organization with id %s not found: %w", orgID, err) + } + return organization, nil +} + +// findProject tries to find if the group already exists. +func findProject(projectName string, organization *om.Organization, conn om.Connection, log *zap.SugaredLogger) (*om.Project, error) { + project, err := findProjectInsideOrganization(conn, projectName, organization, log) + if err != nil { + return nil, xerrors.Errorf("error finding project %s in organization with id %s: %w", projectName, organization, err) + } + if project != nil { + return project, nil + } + log.Debugf("Project \"%s\" not found in organization %s (\"%s\")", projectName, organization.ID, organization.Name) + return nil, nil +} + +func findProjectInsideOrganization(conn om.Connection, projectName string, organization *om.Organization, log *zap.SugaredLogger) (*om.Project, error) { + // 1. Trying to find the project by name + projects, err := conn.ReadProjectsInOrganizationByName(organization.ID, projectName) + + if err != nil { + if v, ok := err.(*apierror.Error); ok { + if v.ErrorCode == apierror.ProjectNotFound { + // ProjectNotFound is an expected condition. + return nil, nil + } + } + log.Error(err) + } + + if err == nil && len(projects) == 1 { + // there is no error so we need to check if the project found has this name + // (the project found could be just the page of one single project if the OM is old and "name" + // parameter is not supported) + if projects[0].Name == projectName { + return projects[0], nil + } + } + + return nil, xerrors.Errorf("could not find project %s in organization %s", projectName, organization.ID) +} + +func findOrganizationByName(conn om.Connection, name string, log *zap.SugaredLogger) (string, error) { + // 1. We try to find the organization using 'name' filter parameter first + organizations, err := conn.ReadOrganizationsByName(name) + + if err != nil { + if v, ok := err.(*apierror.Error); ok { + if v.ErrorCode == apierror.OrganizationNotFound { + // the "name" API is supported and the organization not found - returning nil + return "", nil + } + } + + log.Error(err) + } + if err == nil && len(organizations) == 1 { + // there is no error so we need to check if the organization found has this name + // (the organization found could be just the page of one single organization if the OM is old and "name" + // parameter is not supported) + if organizations[0].Name == name { + return organizations[0].ID, nil + } + } + + return "", xerrors.Errorf("could not find organization %s: %w", name, err) +} + +func tryCreateProject(organization *om.Organization, projectName, orgId string, conn om.Connection, log *zap.SugaredLogger) (*om.Project, error) { + // We can face the following scenario: for the project "foo" with 'orgId=""' the organization "foo" already exists + // - so we need to reuse its orgId instead of creating the new Organization with the same name (OM API is quite + // poor here - it may create duplicates) + if organization != nil { + orgId = organization.ID + } + // Creating the group as it doesn't exist + log.Infow("Creating the project as it doesn't exist", "orgId", orgId) + if orgId == "" { + log.Infof("Note that as the orgId is not specified the organization with name \"%s\" will be created "+ + "automatically by Ops Manager", projectName) + } + group := &om.Project{ + Name: projectName, + OrgID: orgId, + Tags: []string{}, // Project creation no longer applies the EXTERNALLY_MANAGED tag, this is added afterwards + } + ans, err := conn.CreateProject(group) + + if err != nil { + return nil, xerrors.Errorf("Error creating project \"%s\" in Ops Manager: %w", group, err) + } + + log.Infow("Project successfully created", "id", ans.ID) + + return ans, nil +} diff --git a/controllers/operator/project/projectconfig.go b/controllers/operator/project/projectconfig.go new file mode 100644 index 000000000..2484d8084 --- /dev/null +++ b/controllers/operator/project/projectconfig.go @@ -0,0 +1,106 @@ +package project + +import ( + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" + "golang.org/x/xerrors" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/configmap" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func validateProjectConfig(cmGetter configmap.Getter, projectConfigMap client.ObjectKey) (map[string]string, error) { + data, err := configmap.ReadData(cmGetter, projectConfigMap) + if err != nil { + return nil, err + } + + requiredFields := []string{util.OmBaseUrl, util.OmOrgId} + + for _, requiredField := range requiredFields { + if _, ok := data[requiredField]; !ok { + return nil, xerrors.Errorf(`property "%s" is not specified in ConfigMap %s`, requiredField, projectConfigMap) + } + } + return data, nil +} + +// ReadProjectConfig returns a "Project" config build from a ConfigMap with a series of attributes +// like `projectName`, `baseUrl` and a series of attributes related to SSL. +// If configMap doesn't have a projectName defined - the name of MongoDB resource is used as a name of project +func ReadProjectConfig(cmGetter configmap.Getter, projectConfigMap client.ObjectKey, mdbName string) (mdbv1.ProjectConfig, error) { + data, err := validateProjectConfig(cmGetter, projectConfigMap) + if err != nil { + return mdbv1.ProjectConfig{}, err + } + + baseURL := data[util.OmBaseUrl] + orgID := data[util.OmOrgId] + + projectName := data[util.OmProjectName] + if projectName == "" { + projectName = mdbName + } + + sslRequireValid := true + sslRequireValidData, ok := data[util.SSLRequireValidMMSServerCertificates] + if ok { + sslRequireValid = sslRequireValidData != "false" + } + + sslCaConfigMap, ok := data[util.SSLMMSCAConfigMap] + caFile := "" + if ok { + sslCaConfigMapKey := types.NamespacedName{Name: sslCaConfigMap, Namespace: projectConfigMap.Namespace} + + cacrt, err := configmap.ReadData(cmGetter, sslCaConfigMapKey) + if err != nil { + return mdbv1.ProjectConfig{}, xerrors.Errorf("failed to read the specified ConfigMap %s (%w)", sslCaConfigMapKey, err) + } + for k, v := range cacrt { + if k == util.CaCertMMS { + caFile = v + break + } + } + } + + var useCustomCA bool + useCustomCAData, ok := data[util.UseCustomCAConfigMap] + if ok { + useCustomCA = useCustomCAData != "false" + } + + return mdbv1.ProjectConfig{ + BaseURL: baseURL, + ProjectName: projectName, + OrgID: orgID, + + // Options related with SSL on OM side. + SSLProjectConfig: env.SSLProjectConfig{ + // Relevant to + // + operator (via golang http configuration) + // + curl (via command line argument [--insecure]) + // + automation-agent (via env variable configuration [SSL_REQUIRE_VALID_MMS_CERTIFICATES]) + // + EnvVarSSLRequireValidMMSCertificates and automation agent option + // + -sslRequireValidMMSServerCertificates + SSLRequireValidMMSServerCertificates: sslRequireValid, + + // SSLMMSCAConfigMap is name of the configmap with the CA. This CM + // will be mounted in the database Pods. + SSLMMSCAConfigMap: sslCaConfigMap, + + // This needs to be passed for the operator itself to be able to + // recognize the CA -- as it can't be mounted on an already running + // Pod. + SSLMMSCAConfigMapContents: caFile, + }, + + Credentials: data[util.OmCredentials], + + UseCustomCA: useCustomCA, + }, nil +} diff --git a/controllers/operator/project/projectconfig_test.go b/controllers/operator/project/projectconfig_test.go new file mode 100644 index 000000000..8ae8f835f --- /dev/null +++ b/controllers/operator/project/projectconfig_test.go @@ -0,0 +1,162 @@ +package project + +import ( + "context" + "testing" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/mock" + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/configmap" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" +) + +func TestSSLOptionsArePassedCorrectly_SSLRequireValidMMSServerCertificates(t *testing.T) { + client := mock.NewClient() + + cm := defaultConfigMap("cm1") + cm.Data[util.SSLRequireValidMMSServerCertificates] = "true" + client.Create(context.TODO(), &cm) + + projectConfig, err := ReadProjectConfig(client, kube.ObjectKey(mock.TestNamespace, "cm1"), "") + + assert.NoError(t, err) + assert.True(t, projectConfig.SSLProjectConfig.SSLRequireValidMMSServerCertificates) + + assert.Equal(t, projectConfig.SSLMMSCAConfigMap, "") + assert.Equal(t, projectConfig.SSLMMSCAConfigMapContents, "") + + cm = defaultConfigMap("cm2") + cm.Data[util.SSLRequireValidMMSServerCertificates] = "1" + client.Create(context.TODO(), &cm) + + projectConfig, err = ReadProjectConfig(client, kube.ObjectKey(mock.TestNamespace, "cm2"), "") + + assert.NoError(t, err) + assert.True(t, projectConfig.SSLProjectConfig.SSLRequireValidMMSServerCertificates) + + assert.Equal(t, projectConfig.SSLMMSCAConfigMap, "") + assert.Equal(t, projectConfig.SSLMMSCAConfigMapContents, "") + + cm = defaultConfigMap("cm3") + // Setting this attribute to "false" will make it false, any other + // value will result in this attribute being set to true. + cm.Data[util.SSLRequireValidMMSServerCertificates] = "false" + client.Create(context.TODO(), &cm) + + projectConfig, err = ReadProjectConfig(client, kube.ObjectKey(mock.TestNamespace, "cm3"), "") + + assert.NoError(t, err) + assert.False(t, projectConfig.SSLProjectConfig.SSLRequireValidMMSServerCertificates) + + assert.Equal(t, projectConfig.SSLMMSCAConfigMap, "") + assert.Equal(t, projectConfig.SSLMMSCAConfigMapContents, "") +} + +func TestSSLOptionsArePassedCorrectly_SSLMMSCAConfigMap(t *testing.T) { + client := mock.NewClient() + + // This represents the ConfigMap holding the CustomCA + cm := defaultConfigMap("configmap-with-ca-entry") + cm.Data["mms-ca.crt"] = "---- some cert ----" + cm.Data["this-field-is-not-required"] = "bla bla" + client.Create(context.TODO(), &cm) + + // The second CM (the "Project" one) refers to the previous one, where + // the certificate entry is stored. + cm = defaultConfigMap("cm") + cm.Data[util.SSLMMSCAConfigMap] = "configmap-with-ca-entry" + cm.Data[util.SSLRequireValidMMSServerCertificates] = "false" + client.Create(context.TODO(), &cm) + + projectConfig, err := ReadProjectConfig(client, kube.ObjectKey(mock.TestNamespace, "cm"), "") + + assert.NoError(t, err) + assert.False(t, projectConfig.SSLProjectConfig.SSLRequireValidMMSServerCertificates) + + assert.Equal(t, projectConfig.SSLMMSCAConfigMap, "configmap-with-ca-entry") + assert.Equal(t, projectConfig.SSLMMSCAConfigMapContents, "---- some cert ----") +} + +func TestSSLOptionsArePassedCorrectly_UseCustomCAConfigMap(t *testing.T) { + client := mock.NewClient() + + // Passing "false" results in false to UseCustomCA + cm := defaultConfigMap("cm") + cm.Data[util.UseCustomCAConfigMap] = "false" + client.Create(context.TODO(), &cm) + + projectConfig, err := ReadProjectConfig(client, kube.ObjectKey(mock.TestNamespace, "cm"), "") + + assert.NoError(t, err) + assert.False(t, projectConfig.UseCustomCA) + + // Passing "true" results in true to UseCustomCA + cm = defaultConfigMap("cm2") + cm.Data[util.UseCustomCAConfigMap] = "true" + client.Create(context.TODO(), &cm) + + projectConfig, err = ReadProjectConfig(client, kube.ObjectKey(mock.TestNamespace, "cm2"), "") + + assert.NoError(t, err) + assert.True(t, projectConfig.UseCustomCA) + + // Passing any value different from "false" results in true. + cm = defaultConfigMap("cm3") + cm.Data[util.UseCustomCAConfigMap] = "" + client.Create(context.TODO(), &cm) + + projectConfig, err = ReadProjectConfig(client, kube.ObjectKey(mock.TestNamespace, "cm3"), "") + assert.NoError(t, err) + assert.True(t, projectConfig.UseCustomCA) + + // "1" also results in a true value + cm = defaultConfigMap("cm4") + cm.Data[util.UseCustomCAConfigMap] = "1" + client.Create(context.TODO(), &cm) + + projectConfig, err = ReadProjectConfig(client, kube.ObjectKey(mock.TestNamespace, "cm4"), "") + assert.NoError(t, err) + assert.True(t, projectConfig.UseCustomCA) + + // This last section only tests that the unit test is working fine + // and having multiple ConfigMaps in the mocked client will not + // result in contaminated checks. + cm = defaultConfigMap("cm5") + cm.Data[util.UseCustomCAConfigMap] = "false" + client.Create(context.TODO(), &cm) + + projectConfig, err = ReadProjectConfig(client, kube.ObjectKey(mock.TestNamespace, "cm5"), "") + assert.NoError(t, err) + assert.False(t, projectConfig.UseCustomCA) +} + +func TestMissingRequiredFieldsFromCM(t *testing.T) { + client := mock.NewClient() + + t.Run("missing url", func(t *testing.T) { + cm := defaultConfigMap("cm1") + delete(cm.Data, util.OmBaseUrl) + client.Create(context.TODO(), &cm) + _, err := ReadProjectConfig(client, kube.ObjectKey(mock.TestNamespace, "cm1"), "") + assert.Error(t, err) + }) + t.Run("missing orgID", func(t *testing.T) { + cm := defaultConfigMap("cm1") + delete(cm.Data, util.OmOrgId) + client.Create(context.TODO(), &cm) + _, err := ReadProjectConfig(client, kube.ObjectKey(mock.TestNamespace, "cm1"), "") + assert.Error(t, err) + }) +} + +func defaultConfigMap(name string) corev1.ConfigMap { + return configmap.Builder(). + SetName(name). + SetNamespace(mock.TestNamespace). + SetDataField(util.OmBaseUrl, "http://mycompany.com:8080"). + SetDataField(util.OmOrgId, "123abc"). + SetDataField(util.OmProjectName, "my-name"). + Build() +} diff --git a/controllers/operator/secrets/secrets.go b/controllers/operator/secrets/secrets.go new file mode 100644 index 000000000..bb13c6014 --- /dev/null +++ b/controllers/operator/secrets/secrets.go @@ -0,0 +1,180 @@ +package secrets + +import ( + "encoding/base64" + "fmt" + "reflect" + "strings" + + "github.com/10gen/ops-manager-kubernetes/pkg/vault" + kubernetesClient "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/client" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/secret" + "golang.org/x/xerrors" + corev1 "k8s.io/api/core/v1" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" +) + +type SecretClientInterface interface { + ReadSecret(secretName types.NamespacedName, basePath string) (map[string]string, error) +} + +var _ SecretClientInterface = (*SecretClient)(nil) + +type SecretClient struct { + VaultClient *vault.VaultClient + KubeClient kubernetesClient.KubernetesSecretClient +} + +func namespacedNameToVaultPath(nsName types.NamespacedName, basePath string) string { + return fmt.Sprintf("%s/%s/%s", basePath, nsName.Namespace, nsName.Name) +} + +func secretNamespacedName(s corev1.Secret) types.NamespacedName { + return types.NamespacedName{ + Namespace: s.Namespace, + Name: s.Name, + } +} + +func (r SecretClient) ReadSecretKey(secretName types.NamespacedName, basePath string, key string) (string, error) { + secret, err := r.ReadSecret(secretName, basePath) + if err != nil { + return "", xerrors.Errorf("can't read secret %s: %w", secretName, err) + } + val, ok := secret[key] + if !ok { + return "", xerrors.Errorf("secret %s does not contain key %s", secretName, key) + } + return val, nil +} + +func (r SecretClient) ReadSecret(secretName types.NamespacedName, basePath string) (map[string]string, error) { + secrets := make(map[string]string) + if vault.IsVaultSecretBackend() { + var err error + secretPath := namespacedNameToVaultPath(secretName, basePath) + secrets, err = r.VaultClient.ReadSecretString(secretPath) + if err != nil { + return nil, err + } + } else { + stringData, err := secret.ReadStringData(r.KubeClient, secretName) + if err != nil { + return nil, err + } + for k, v := range stringData { + secrets[k] = strings.TrimSuffix(v[:], "\n") + } + } + return secrets, nil +} + +// PutSecret copies secret.Data into vault. Note: we don't rely on secret.StringData since our builder does not use the field. +func (r SecretClient) PutSecret(s corev1.Secret, basePath string) error { + if vault.IsVaultSecretBackend() { + secretPath := namespacedNameToVaultPath(secretNamespacedName(s), basePath) + secretData := map[string]interface{}{} + for k, v := range s.Data { + secretData[k] = string(v) + } + data := map[string]interface{}{ + "data": secretData, + } + return r.VaultClient.PutSecret(secretPath, data) + } + + return secret.CreateOrUpdate(r.KubeClient, s) +} + +// PutBinarySecret copies secret.Data as base64 into vault. +func (r SecretClient) PutBinarySecret(s corev1.Secret, basePath string) error { + if vault.IsVaultSecretBackend() { + secretPath := namespacedNameToVaultPath(secretNamespacedName(s), basePath) + secretData := map[string]interface{}{} + for k, v := range s.Data { + secretData[k] = base64.StdEncoding.EncodeToString(v) + } + data := map[string]interface{}{ + "data": secretData, + } + return r.VaultClient.PutSecret(secretPath, data) + } + + return secret.CreateOrUpdate(r.KubeClient, s) +} + +// PutSecretIfChanged updates a Secret only if it has changed. Equality is based on s.Data. +// `basePath` is only used when Secrets backend is `Vault`. +func (r SecretClient) PutSecretIfChanged(s corev1.Secret, basePath string) error { + if vault.IsVaultSecretBackend() { + secret, err := r.ReadSecret(secretNamespacedName(s), basePath) + if err != nil && !strings.Contains(err.Error(), "not found") { + return err + } + if err != nil || !reflect.DeepEqual(secret, DataToStringData(s.Data)) { + return r.PutSecret(s, basePath) + } + } + + return secret.CreateOrUpdateIfNeeded(r.KubeClient, s) +} + +func SecretNotExist(err error) bool { + if err == nil { + return false + } + return apiErrors.IsNotFound(err) || strings.Contains(err.Error(), "secret not found") +} + +// These methods implement the secretGetterUpdateCreateDeleter interface from community. +// We hardcode here the AppDB sub-path for Vault since community is used only to deploy +// AppDB pods. This allows us to minimize the changes to Community. + +func (r SecretClient) GetSecret(secretName types.NamespacedName) (corev1.Secret, error) { + if vault.IsVaultSecretBackend() { + s := corev1.Secret{} + + data, err := r.ReadSecret(secretName, r.VaultClient.AppDBSecretPath()) + if err != nil { + return s, err + } + s.Data = make(map[string][]byte) + for k, v := range data { + s.Data[k] = []byte(v) + } + return s, nil + } + return r.KubeClient.GetSecret(secretName) +} + +func (r SecretClient) CreateSecret(s corev1.Secret) error { + var appdbSecretPath string + if r.VaultClient != nil { + appdbSecretPath = r.VaultClient.AppDBSecretPath() + } + return r.PutSecret(s, appdbSecretPath) +} + +func (r SecretClient) UpdateSecret(s corev1.Secret) error { + if vault.IsVaultSecretBackend() { + return r.CreateSecret(s) + } + return r.KubeClient.UpdateSecret(s) +} + +func (r SecretClient) DeleteSecret(secretName types.NamespacedName) error { + if vault.IsVaultSecretBackend() { + // TODO deletion logic + return nil + } + return r.KubeClient.DeleteSecret(secretName) +} + +func DataToStringData(data map[string][]byte) map[string]string { + stringData := make(map[string]string) + for k, v := range data { + stringData[k] = string(v) + } + return stringData +} diff --git a/controllers/operator/testdata/certificates/cert_auto b/controllers/operator/testdata/certificates/cert_auto new file mode 100644 index 000000000..b25135597 --- /dev/null +++ b/controllers/operator/testdata/certificates/cert_auto @@ -0,0 +1,137 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 3 (0x3) + Signature Algorithm: sha256WithRSAEncryption + Issuer: C=US, ST=New York, L=New York, O=MongoDB, OU=mongodb, CN=www.mongodb.com + Validity + Not Before: Jul 16 09:51:56 2020 GMT + Not After : Apr 12 09:51:56 2023 GMT + Subject: C=US, ST=New York, L=New York, O=MongoDB, OU=cloud, CN=mms-automation-agent + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (2048 bit) + Modulus: + 00:a8:9c:e4:7b:36:ca:65:61:89:41:69:81:40:c8: + b3:5d:fa:0b:4f:48:4d:29:b2:20:36:1d:42:f5:f9: + 80:c1:b8:42:45:46:c9:b3:38:8c:00:c8:d3:e0:f2: + 7d:62:ca:15:38:52:43:0b:56:ec:14:94:81:87:5d: + 58:a6:23:d2:a9:29:81:9e:5b:b4:d2:9e:79:b3:5b: + 36:bf:9f:1f:e5:a4:06:73:85:08:98:8c:7b:66:50: + 8b:fd:5d:45:14:61:97:1e:f7:93:f1:de:7d:7e:8f: + 6d:b4:ff:f6:e7:22:38:df:e5:6e:7b:00:5b:1f:30: + 9d:e0:4f:9f:b0:86:a2:60:69:1c:ee:e1:b2:f9:1f: + 92:fb:c0:bc:56:8b:04:a4:e9:a3:33:04:68:7a:c5: + a9:92:45:82:52:4e:62:19:36:31:b1:e1:ec:83:34: + f8:2d:47:b7:b9:e7:71:4c:b6:2f:1c:f4:a2:87:a3: + 10:41:62:e0:1a:c6:36:ee:5d:23:fd:7d:ef:b0:28: + fe:66:c7:51:6d:16:34:28:67:6c:fc:48:fa:bc:01: + 0c:31:ef:08:0a:bf:38:9d:6b:1f:ab:5f:5c:8b:9a: + 81:82:14:07:2a:20:ea:96:d1:ff:b3:ad:cd:80:23: + dc:f5:e1:6f:8e:38:f9:0e:1d:69:ff:ef:d4:48:98: + 34:ab + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Subject Key Identifier: + 70:73:AF:1D:81:B8:F5:5B:72:DE:04:03:65:C9:76:38:9D:0E:6C:BB + X509v3 Authority Key Identifier: + keyid:13:DA:D9:CD:E0:CC:51:84:5D:2C:7C:A5:46:33:4E:02:E4:F2:D9:6C + + X509v3 Basic Constraints: + CA:FALSE + X509v3 Key Usage: + Digital Signature, Key Encipherment + X509v3 Extended Key Usage: + TLS Web Client Authentication + X509v3 Subject Alternative Name: + DNS:ec2-3-82-109-30.compute-1.amazonaws.com + Netscape Comment: + OpenSSL Generated Client Certificate + Signature Algorithm: sha256WithRSAEncryption + 33:b1:fb:61:13:38:5e:9e:e5:c0:99:95:4f:7d:7a:5c:b7:2e: + ca:3e:3f:f3:03:7c:a6:34:fc:e9:87:22:4a:cd:00:9c:15:c2: + aa:31:6b:31:2f:e3:72:2b:6c:45:eb:c7:ce:39:5d:df:ab:3e: + eb:80:db:ce:2d:f3:2b:79:06:7d:af:06:66:d9:1b:8c:f7:ff: + 26:d4:7b:69:a2:d8:b9:09:e5:f9:00:59:9b:05:31:27:c5:74: + 6b:49:1d:4b:cf:f1:0d:b5:21:d0:3c:ca:92:91:1c:2c:97:77: + c8:4c:20:5a:3d:43:f4:7b:9a:0d:6d:3d:88:5a:4a:74:1b:52: + d1:32:72:1d:cb:78:a0:89:c4:98:4d:1e:5a:26:d8:a7:8a:aa: + 86:70:21:72:7e:20:a2:11:39:61:08:51:22:94:1c:2b:d6:8a: + 84:16:6f:6b:4c:7c:6f:9b:b2:ec:a8:4d:7a:5b:e3:cc:73:d0: + fe:7b:30:b7:6d:1d:7d:41:da:a5:a5:19:47:9b:ce:5d:0c:78: + 13:4c:bd:cd:71:72:da:de:b5:ca:f4:0e:7e:8c:a3:30:55:ff: + 94:0f:6d:2c:54:40:4f:6d:92:8c:0b:d7:33:3b:cd:9d:4a:b8: + 00:9e:c8:1b:d6:e7:7c:0c:cf:fe:a8:3f:97:0a:13:bd:ca:6a: + 60:9e:ea:ee:e7:9e:42:c0:4d:c1:ec:a7:93:68:bd:16:84:f3: + 11:3d:51:61:d8:a5:1a:63:c1:59:e3:d4:ef:26:17:47:87:99: + f8:0b:63:09:6b:84:2c:ab:e4:56:9c:15:af:df:a7:ac:9f:8f: + 73:e2:3b:57:bc:98:91:f7:e0:5e:ff:e2:65:40:a5:25:c9:aa: + f1:8f:1d:a5:17:26:ee:9d:4c:6b:28:57:50:5f:c1:48:d0:f2: + c9:ea:6b:28:c4:d2:c5:b8:e9:79:f2:86:92:34:20:3c:52:2f: + c2:00:9d:b6:51:f7:61:d5:69:14:d6:75:66:89:f8:77:98:06: + a8:c0:0a:0d:82:ac:3a:40:9b:02:7e:ba:5c:e7:58:37:e9:6a: + ad:c6:93:35:67:ca:6b:ae:eb:84:5c:bf:0a:e1:94:67:37:84: + 05:3e:4a:00:39:ff:dc:30:04:c2:4a:19:63:2f:0a:fe:76:5d: + 3a:8e:d7:dd:22:b8:84:1d:fa:5e:65:c5:7b:44:6d:bd:0f:15: + 15:bb:cf:cf:fb:29:4f:61:4c:81:51:9d:86:0a:be:e7:02:ec: + d6:7f:9a:36:b8:73:94:af:aa:a7:6b:c8:33:a2:fb:ab:53:30: + 4a:1f:ec:2f:9d:ec:df:26:18:16:5b:c0:bc:25:8e:1b:13:68: + b6:18:28:0d:bd:09:19:e7 +-----BEGIN CERTIFICATE----- +MIIFOjCCAyKgAwIBAgIBAzANBgkqhkiG9w0BAQsFADBxMQswCQYDVQQGEwJVUzER +MA8GA1UECAwITmV3IFlvcmsxETAPBgNVBAcMCE5ldyBZb3JrMRAwDgYDVQQKDAdN +b25nb0RCMRAwDgYDVQQLDAdtb25nb2RiMRgwFgYDVQQDDA93d3cubW9uZ29kYi5j +b20wHhcNMjAwNzE2MDk1MTU2WhcNMjMwNDEyMDk1MTU2WjB0MQswCQYDVQQGEwJV +UzERMA8GA1UECAwITmV3IFlvcmsxETAPBgNVBAcMCE5ldyBZb3JrMRAwDgYDVQQK +DAdNb25nb0RCMQ4wDAYDVQQLDAVjbG91ZDEdMBsGA1UEAwwUbW1zLWF1dG9tYXRp +b24tYWdlbnQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQConOR7Nspl +YYlBaYFAyLNd+gtPSE0psiA2HUL1+YDBuEJFRsmzOIwAyNPg8n1iyhU4UkMLVuwU +lIGHXVimI9KpKYGeW7TSnnmzWza/nx/lpAZzhQiYjHtmUIv9XUUUYZce95Px3n1+ +j220//bnIjjf5W57AFsfMJ3gT5+whqJgaRzu4bL5H5L7wLxWiwSk6aMzBGh6xamS +RYJSTmIZNjGx4eyDNPgtR7e553FMti8c9KKHoxBBYuAaxjbuXSP9fe+wKP5mx1Ft +FjQoZ2z8SPq8AQwx7wgKvzidax+rX1yLmoGCFAcqIOqW0f+zrc2AI9z14W+OOPkO +HWn/79RImDSrAgMBAAGjgdkwgdYwHQYDVR0OBBYEFHBzrx2BuPVbct4EA2XJdjid +Dmy7MB8GA1UdIwQYMBaAFBPa2c3gzFGEXSx8pUYzTgLk8tlsMAkGA1UdEwQCMAAw +CwYDVR0PBAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMCMDIGA1UdEQQrMCmCJ2Vj +Mi0zLTgyLTEwOS0zMC5jb21wdXRlLTEuYW1hem9uYXdzLmNvbTAzBglghkgBhvhC +AQ0EJhYkT3BlblNTTCBHZW5lcmF0ZWQgQ2xpZW50IENlcnRpZmljYXRlMA0GCSqG +SIb3DQEBCwUAA4ICAQAzsfthEzhenuXAmZVPfXpcty7KPj/zA3ymNPzphyJKzQCc +FcKqMWsxL+NyK2xF68fOOV3fqz7rgNvOLfMreQZ9rwZm2RuM9/8m1Htpoti5CeX5 +AFmbBTEnxXRrSR1Lz/ENtSHQPMqSkRwsl3fITCBaPUP0e5oNbT2IWkp0G1LRMnId +y3igicSYTR5aJtiniqqGcCFyfiCiETlhCFEilBwr1oqEFm9rTHxvm7LsqE16W+PM +c9D+ezC3bR19QdqlpRlHm85dDHgTTL3NcXLa3rXK9A5+jKMwVf+UD20sVEBPbZKM +C9czO82dSrgAnsgb1ud8DM/+qD+XChO9ympgnuru555CwE3B7KeTaL0WhPMRPVFh +2KUaY8FZ49TvJhdHh5n4C2MJa4Qsq+RWnBWv36esn49z4jtXvJiR9+Be/+JlQKUl +yarxjx2lFybunUxrKFdQX8FI0PLJ6msoxNLFuOl58oaSNCA8Ui/CAJ22Ufdh1WkU +1nVmifh3mAaowAoNgqw6QJsCfrpc51g36WqtxpM1Z8prruuEXL8K4ZRnN4QFPkoA +Of/cMATCShljLwr+dl06jtfdIriEHfpeZcV7RG29DxUVu8/P+ylPYUyBUZ2GCr7n +AuzWf5o2uHOUr6qna8gzovurUzBKH+wvnezfJhgWW8C8JY4bE2i2GCgNvQkZ5w== +-----END CERTIFICATE----- +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQConOR7NsplYYlB +aYFAyLNd+gtPSE0psiA2HUL1+YDBuEJFRsmzOIwAyNPg8n1iyhU4UkMLVuwUlIGH +XVimI9KpKYGeW7TSnnmzWza/nx/lpAZzhQiYjHtmUIv9XUUUYZce95Px3n1+j220 +//bnIjjf5W57AFsfMJ3gT5+whqJgaRzu4bL5H5L7wLxWiwSk6aMzBGh6xamSRYJS +TmIZNjGx4eyDNPgtR7e553FMti8c9KKHoxBBYuAaxjbuXSP9fe+wKP5mx1FtFjQo +Z2z8SPq8AQwx7wgKvzidax+rX1yLmoGCFAcqIOqW0f+zrc2AI9z14W+OOPkOHWn/ +79RImDSrAgMBAAECggEAcpY9CCdSINfKKXQD7Pz4OLOHIBgoqF9vWJdGPFeVUxFf +qCjVRkD1lErnAwaIg6yGA0KUYY5u3gWWiWG8rxvFPEUC25XDKyeb2XHxoQQI700r +PTJ5hwJhkkTG/iZ2ncU8qETkfAkSDAJ5MfqJ1sYBFNec32Z8hpPJlvlFsvesPgvW +xGl13TinIl9TtLVu5OU5f3hFD+sODUsS1QQtwzXiOSF7JU/4gA4hJEPiSD4tbZwy +moVjglPVaasVqZDsLqz9RL8mL53QYmzGcSRr5egQsgps5x2IN8sEU7BiUej3qt/k +aUzUuPyQjkH/gdrX71eEY+NC+Zv8e/RSy0z3zdWmcQKBgQDQJ7j+7asO0E5Hibk/ +862dGLVIVxn17CWUPeubgYNLEMZAyqCr85mnnpU89tc8mUOyglZkfqvvdl2sOKK6 +zy4yaiu9Yq3sciXUDEKiSJsZf+GDmK9mQ5LLjogWdq7uB/2uR/DkEbry2gFskUdO +3GIT1QROt8T1XM4YzF5Hu49ddQKBgQDPXmtgmrOLtH9UwtWiGX08vQKv2Oa+jnLK +f+Z3mNDinIjSY2l3KXecpvF20J7TXK0zQWk84p6GQF55sT3PL3oxk/OOwfmwZC0m +FGEL/BGIkS+Hwxc9UfGZ7wck66YiRl2RXF1XF4RpRuBKdXGreORsjl0Aqt34fvzy +CpZe9DZlnwKBgGTn/LxIRrZFsMzpLM6duDoBsk/BOaqHsaftZHvcCuOm3BSopb71 +tjUVoU8OckTEH5c3q93Hsl3BSaOlSO26ZbC220FRxvJqW4Ax+VNmUxnHbnE24UB3 +3X+kNsB9BEwLv6Ru544IMlJr8GjK/IB0QW9PwmjOmUJAnQBUghfQCq3JAoGANy0q +aRQAviWS09zbtzwNBMJOGrgd/YotpRAPJLd2rTV1enWVNG3GM9p/2Vt9R0Qbmc3H +0LmD8Ljj6oFsrto1K0fwwIWAiJy/HqjBgczaZXosKXWRk3FgVdMyFXLWS7xpXSo0 +c94AD3saZvWE/1k1fmUK/gh484vmhginJjDY4IUCgYB2aZ/e8MoJFTu0ADzJBaKz +L+BYwimpdfj2e5UZ/mg95zDGiHAfxe1wZ99rLWZZq+jaRdoaL97K/kh12EDYltvr +EqLjvAISbuWUvSZmJajbpnb9knKZINhunSKcPT9roqD3iitaZ5QgYncAZgloDG0x ++oMJvhAlzNLO7gAbcMuEfw== +-----END PRIVATE KEY----- diff --git a/controllers/operator/testdata/certificates/cert_rfc2253 b/controllers/operator/testdata/certificates/cert_rfc2253 new file mode 100644 index 000000000..92715cffd --- /dev/null +++ b/controllers/operator/testdata/certificates/cert_rfc2253 @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDZDCCAkwCCQD0zPhiubeUgjANBgkqhkiG9w0BAQsFADB0MQswCQYDVQQGEwJJ +TjESMBAGA1UECAwJTmV3IERlbGhpMRIwEAYDVQQHDAlOZXcgRGVsaGkxDDAKBgNV +BAsMA1RTRTEWMBQGA1UECgwNTW9uZ29EQi1hZ2VudDEXMBUGA1UEAwwObW1zLWFn +ZW50LWNlcnQwHhcNMjEwOTI4MTUzNjA5WhcNMjIwOTI4MTUzNjA5WjB0MQswCQYD +VQQGEwJJTjESMBAGA1UECAwJTmV3IERlbGhpMRIwEAYDVQQHDAlOZXcgRGVsaGkx +DDAKBgNVBAsMA1RTRTEWMBQGA1UECgwNTW9uZ29EQi1hZ2VudDEXMBUGA1UEAwwO +bW1zLWFnZW50LWNlcnQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6 +oh/i2rT90EOLlUHsqnkdaqyG4CiXe44lzI2zRHunO928lkyoBPLHLTYUriCdm2WO +ljaSZguSmGDfNCCPAufqVhbtusvS9dz5QBNwyRGEY8S246+EAtmqSBeb11OjQpbT +1mGqjDMEcFR/1NXdcPXmvKml3L60Clauuxeb4EsnmWfXh7QSGGnYK8ybQobF6ddE +qrygJHCGzh53hKCwAhq15h+fccaKgBixOPq9eqPx+kvDmsHMEc2ScF6S2KZ+Z/dV +cUnWgY9lWymm2MgfIorwXId4IiGfQT3fQXwUR38yP19+3lGUYDGcD7LW37LqZUI5 +IFVFkPuihO0YpeIH/ogxAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAD3zys7SWzO0 +eP3oG77798OhILyp9E6415bdrY7vrloxpFd6YB4JvEVxQbdIs+k4BgxbOtXt2DqX +K1xMF4MASZpmCL0QynqBfknZhzx9DTqJAli30Xbk7zSS5jaBWZ3v0QxMkV8x0mtS +E9owEOLO6ikTXYgjkllAmbQdYgMnOCos62RDcRbD5Ht5Su5ghokTx5a6eWDB3bPR +YQazxu4jHXZ8M0iDOpl3xGh0GxRaosb+f8U6prZoj+YGpa18H2YmwEygvhSzsUMq +PeHOzlBN/Pur/5N6sRHtP15rRTSdV91BE29zjgDEVKkZNGdb4AruFrJcj+zLS/vO +cNBafZbYWmc= +-----END CERTIFICATE----- diff --git a/controllers/operator/testdata/certificates/certificate_then_key b/controllers/operator/testdata/certificates/certificate_then_key new file mode 100644 index 000000000..28d364921 --- /dev/null +++ b/controllers/operator/testdata/certificates/certificate_then_key @@ -0,0 +1,79 @@ +-----BEGIN CERTIFICATE----- +MIIEgjCCA2qgAwIBAgIRAJm5VGPguqlg4byYvf0RVFEwDQYJKoZIhvcNAQELBQAw +FTETMBEGA1UEAxMKa3ViZXJuZXRlczAeFw0yMDA0MDMwODMyNDVaFw0yMTA0MDMw +ODMyNDVaMIGLMQswCQYDVQQGEwJVUzELMAkGA1UECBMCTlkxCzAJBgNVBAcTAk5Z +MR0wGwYDVQQKExRtbXMtYXV0b21hdGlvbi1hZ2VudDEkMCIGA1UECxMbTW9uZ29E +QiBLdWJlcm5ldGVzIE9wZXJhdG9yMR0wGwYDVQQDExRtbXMtYXV0b21hdGlvbi1h +Z2VudDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAKqkwhcQNPvRLITz +IN1Fbod1lpIn2VthrGIYI6J02h7mAW+cULIF7qKcsc1FnqqMY4vL9X0XY2+lBtlI +3L79Gn4pRmQugjqvupJKFQiVbAyZOZgc9d3MefyrRTmzpf+QBVQ6pLg8jDqAhTub +jPMWJ8+OW5RRW+C3Vu8wjgQuQDrDp2xE4/j2FzW6G+4+gpXJ2JtrOOa1IIUGDFLq +3EUeU8BMXKUNZITKeraxbnXTiLEt//QqbFS6WooRxGRBWEcGM6Qqd5kv0bDJpJjl +HU2QoQVoZD2p/cV/nyVs4x4pXalZ68cfuMkD+UR8M9YUHy0QOvfJ3CmWeWsZxyEO +nmGGc/4u15fVkltlCcdMfDMdDGBPo5LjZbiyCItQjKVU9xvHerzHvD7ZPM4P8W95 +c94xVmCmO4JIhCLV3cnTTiY6M4CHSZglldgZdSbMjYUc/fmG1LxHlR6mWurzo0Pa +ZJrix9foBK11W3zi2X799ZNro/4y6u7r4equAqI9zdlcoA+Pstn0J6dgVwKE6C9W +EZOhAZNoiWJtkAWzA6/8vncuDeTEnRh41gNWc0l1Iem2vrJ8zLIvCK+NzMUBYCLn +M+qKagp0Y/PHMvdudgMbFtLNiZvqVJbtAkdW/uDgr4SJ+dxNTZDc++clLodycs2m +8UNq5j1kSf87OrkW6+LWKjnhlz0TAgMBAAGjVjBUMA4GA1UdDwEB/wQEAwIFoDAT +BgNVHSUEDDAKBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB8GA1UdEQQYMBaCFG1t +cy1hdXRvbWF0aW9uLWFnZW50MA0GCSqGSIb3DQEBCwUAA4IBAQBSIgTFY0F3dc0X +4CZbseynPDd/BRwgQlC5wXsbD6kYmnDhdpPBstlWjj4l6HtKvVsVsZxcghT9SI0l +H1m4imhh/scfVqjkBTTPikyJTfgUnN1RIJlDN+XzRz4HxvP1qVIdTlNfDW3avAvG +Cb2iHwEwH3Xv+s+3m4/FsGm/rtRTDerafkYrtCdCyY77k7/tquFRn68mvZnthiwn +zv3iFWHtdg7idbFYxyCmLXiBNzw54cEqdvehPV9UwbnupM/KEDZTL2Edcxl5gxWV +JAG35yxcvdf2zYbGwf/BWpOwMZmzDP06IxgpAEfPBj7g83Fjj6pn2MM+dQIomFB+ +/m6HRiJE +-----END CERTIFICATE----- +-----BEGIN PRIVATE KEY----- +MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQCqpMIXEDT70SyE +8yDdRW6HdZaSJ9lbYaxiGCOidNoe5gFvnFCyBe6inLHNRZ6qjGOLy/V9F2NvpQbZ +SNy+/Rp+KUZkLoI6r7qSShUIlWwMmTmYHPXdzHn8q0U5s6X/kAVUOqS4PIw6gIU7 +m4zzFifPjluUUVvgt1bvMI4ELkA6w6dsROP49hc1uhvuPoKVydibazjmtSCFBgxS +6txFHlPATFylDWSEynq2sW5104ixLf/0KmxUulqKEcRkQVhHBjOkKneZL9GwyaSY +5R1NkKEFaGQ9qf3Ff58lbOMeKV2pWevHH7jJA/lEfDPWFB8tEDr3ydwplnlrGcch +Dp5hhnP+LteX1ZJbZQnHTHwzHQxgT6OS42W4sgiLUIylVPcbx3q8x7w+2TzOD/Fv +eXPeMVZgpjuCSIQi1d3J004mOjOAh0mYJZXYGXUmzI2FHP35htS8R5Ueplrq86ND +2mSa4sfX6AStdVt84tl+/fWTa6P+Muru6+HqrgKiPc3ZXKAPj7LZ9CenYFcChOgv +VhGToQGTaIlibZAFswOv/L53Lg3kxJ0YeNYDVnNJdSHptr6yfMyyLwivjczFAWAi +5zPqimoKdGPzxzL3bnYDGxbSzYmb6lSW7QJHVv7g4K+EifncTU2Q3PvnJS6HcnLN +pvFDauY9ZEn/Ozq5Fuvi1io54Zc9EwIDAQABAoICAEMUFP+/7TP328o/UHHqszIo +dRHq/DRBxuOgnZFk4cE3pOTcy5PPZSki83m/nkloelEf0dZkdUAT3QdY7v1cvSdO +zk7fQW4UWgDbgj0nj5u8N7ml2LhhgqpiIQo3pk85q/6aNtn9Yxo0Hyt5UATWdrvO +OA2rlbRWHaRUr97Q14rCEnQq+HqLMkB6cjRK+kYrXCxsD6gRF0FzSTDnBcNd0opK ++jgfdZ4FgguC3+sNRjRv4qd2bbM4thKEPXEzhqIUvAQSdYUQGRuniD5aAhTVf5aC +nLTot8sFCehKT1Ux6ZGCuX5C5/6Mw1W6hR3oNwEd2jBBd3wZnI0PSwmhl3y6v6lM +pmMmO9Tl4fG7bVlRJsktxLsCDanBSRaqn1lsbjmyic3Q82H43VebZV37e/JOYUkH +iXd1gX6a3O/x6om2IJmt48Hm71TJGxLh+NeyEtLa6eOupOK+56qU1ByJB1S/sBz5 +IkbWMaKRXu86XOK+mdnI2jURJ7W14N2zCslE6IkU7Ehx9Bg4qxvyR6/z8B7IzYzh +bWj+ilKjQjoSphGh9sDE3ZXuqIa1HP9U/n5r+03ulCPwuoGwZtwL9QCXk05Iedie +1a2rRSy8QKGVk4mHDRtgFP61jtj00u5XMeGvVMVtEXcs6UlRfK6q9K994aeoPTL9 +IyhygaqFcmrpff2QmVLBAoIBAQDBGmIa4iuMwKB3KLVSiddD9Un+5eOW3gvpmDS+ +v+Y0zyFe5QebV6UVXTXIIsVKPtXdKW6yaCGHAWwBQRemMHX+bNJCigyRKz9HrENu +fZxP80mn5Ev+LaQOjZUMCiu0hNF/dSBoxpk1NV3aKEs3re0Xv2PKvnHMtws7DgeI +ePpJ3HRnEwsHY5kPQnL9DFcwX3Nq9wcgKmM0VCItjhJZiJlHqJiM9f15RgUlThXC +tCON1EPDPvyRfu27RKIggcEQ7SEwLEYlB05EUPp5rEUaM6UeNsZvm/DzfpH6NkjZ +SSDU/mx+v36QVhareYwRJpE1tEIezLluGdSld4LQHyDoFlQDAoIBAQDiOZ/kJ/AS +QCHzU1MyEtGpGwJKoFk9j1cKXFJrfVME2k7Ys2nCdo4mtcJmJzRYhK1rBVPFPdmO +V/nnfZGVHypE1qdfgZKRiBdJTLhILwePHWcDDmWW16nxcIjGfAEMJik8TuuWER2a +B7uwWfDcXpn5FfsRRW28FcE5emVWm+x/CrZCDMdO12xElOFMM2qohC15tAoOlNMI +KsfknK+OlFUXVagqDpgzl8nOoHV3aaM00+ChL32nam97hNnTrMHzrGZUCM9PlbCV +WkIvKwEvEQQfAhoUIV5muNGm0G68LFKYYJbXBuehNQ2MfX6xWJAf4a1rtL7rDtYa +UQ4VQg8moQ2xAoIBACT4Kx4gfNv2qQIHLie+MhNVq7P8SUVB/5/aPwbh8G3d1fK4 +AGvSLM3ZSYmmdoUPYJx16TaIzxpswEPBNYjgsEZkiSCqE1vbnsLXDRXjQIDiABD/ +mTjxff43RvjGHbXy07UGNI06sGxKakxw+G2Rg9nPD4jqSxk5VhIZToHnP2vSpApz +z+G7RLtyKled/DdLnuo0nw2eb9292clE8OhpSYc5lPMvyTZlnGiW+X2MRV5K7Co+ +LdahKVx3+F4m2VKnQ3pYj5lZO7fClSGkRJqOlqchL36AqXHEoqf3qpzG7l041Iaz +nMR/ZtmvbIyACL7yYtJIuZuFoHuJVOcJfqBQXgcCggEBANR/PGmb+i2qgDmH84X9 +l2M5M5XVuP3SPvhEcEb3mZvdVGLJZHZ91lkWMlyyRsE/H4Z/ooiL6GeEzAFeOfnR +JGs1FlLn6z04kGcR4agsRPVxsOl2BIcEXWWlR1Tp9jHrRqCXoUN9IEknKm4kjdLy +Kb+HniZDCSi7Zp0PE1GfdS6AaWLxjeXJBLIHBvoE8hMI1Y6URz4bHX92b/2WEHHl +c2hP1X5r5xvPYIjuwGhCmkNtIntFmMpBeCaWS+ZBSI4TSqt0+wbOnOgtuC2GP75u +RWi7GLQABCSJRqVi9CFdoNfxIr8ohTswEmH9H5yGjBrmaXfad9tkPEjMCmZ9fq3S +aoECggEBAIzguZBBHohHaXPTMYC06M80YmMsV6kuDzA6dwCceMKDcQf+CNiwTOI9 +iv5zVWCkSpRlsYgbYgbsnZ7OV59mr83w9V7vsJB+fFWAFCDj9DcUz7xUYqFhxIk+ +qXUbJKOwW5icau5C5HQhlJAG9PmWrz0Pi+U4fofjWVHZwB3nUuPIiAigRLNEDXia +lZeZ2ZwDE7tfbyVhZageUMhFNsGJEV9S0aECq8mgHjHZUy7s3dwdCMeIsfqqJJk3 +FsaDbij2x09H2GqqtGbko3M/SVN5oCpAnkQpUfq767e27t4R3XuYtmPSEv73pCDh +8Ah6kGlS8cImcSWBf7/tAei7L5xLEqo= +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/controllers/operator/testdata/certificates/just_certificate b/controllers/operator/testdata/certificates/just_certificate new file mode 100644 index 000000000..36ac36c57 --- /dev/null +++ b/controllers/operator/testdata/certificates/just_certificate @@ -0,0 +1,27 @@ +-----BEGIN CERTIFICATE----- +MIIEgjCCA2qgAwIBAgIRAJm5VGPguqlg4byYvf0RVFEwDQYJKoZIhvcNAQELBQAw +FTETMBEGA1UEAxMKa3ViZXJuZXRlczAeFw0yMDA0MDMwODMyNDVaFw0yMTA0MDMw +ODMyNDVaMIGLMQswCQYDVQQGEwJVUzELMAkGA1UECBMCTlkxCzAJBgNVBAcTAk5Z +MR0wGwYDVQQKExRtbXMtYXV0b21hdGlvbi1hZ2VudDEkMCIGA1UECxMbTW9uZ29E +QiBLdWJlcm5ldGVzIE9wZXJhdG9yMR0wGwYDVQQDExRtbXMtYXV0b21hdGlvbi1h +Z2VudDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAKqkwhcQNPvRLITz +IN1Fbod1lpIn2VthrGIYI6J02h7mAW+cULIF7qKcsc1FnqqMY4vL9X0XY2+lBtlI +3L79Gn4pRmQugjqvupJKFQiVbAyZOZgc9d3MefyrRTmzpf+QBVQ6pLg8jDqAhTub +jPMWJ8+OW5RRW+C3Vu8wjgQuQDrDp2xE4/j2FzW6G+4+gpXJ2JtrOOa1IIUGDFLq +3EUeU8BMXKUNZITKeraxbnXTiLEt//QqbFS6WooRxGRBWEcGM6Qqd5kv0bDJpJjl +HU2QoQVoZD2p/cV/nyVs4x4pXalZ68cfuMkD+UR8M9YUHy0QOvfJ3CmWeWsZxyEO +nmGGc/4u15fVkltlCcdMfDMdDGBPo5LjZbiyCItQjKVU9xvHerzHvD7ZPM4P8W95 +c94xVmCmO4JIhCLV3cnTTiY6M4CHSZglldgZdSbMjYUc/fmG1LxHlR6mWurzo0Pa +ZJrix9foBK11W3zi2X799ZNro/4y6u7r4equAqI9zdlcoA+Pstn0J6dgVwKE6C9W +EZOhAZNoiWJtkAWzA6/8vncuDeTEnRh41gNWc0l1Iem2vrJ8zLIvCK+NzMUBYCLn +M+qKagp0Y/PHMvdudgMbFtLNiZvqVJbtAkdW/uDgr4SJ+dxNTZDc++clLodycs2m +8UNq5j1kSf87OrkW6+LWKjnhlz0TAgMBAAGjVjBUMA4GA1UdDwEB/wQEAwIFoDAT +BgNVHSUEDDAKBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB8GA1UdEQQYMBaCFG1t +cy1hdXRvbWF0aW9uLWFnZW50MA0GCSqGSIb3DQEBCwUAA4IBAQBSIgTFY0F3dc0X +4CZbseynPDd/BRwgQlC5wXsbD6kYmnDhdpPBstlWjj4l6HtKvVsVsZxcghT9SI0l +H1m4imhh/scfVqjkBTTPikyJTfgUnN1RIJlDN+XzRz4HxvP1qVIdTlNfDW3avAvG +Cb2iHwEwH3Xv+s+3m4/FsGm/rtRTDerafkYrtCdCyY77k7/tquFRn68mvZnthiwn +zv3iFWHtdg7idbFYxyCmLXiBNzw54cEqdvehPV9UwbnupM/KEDZTL2Edcxl5gxWV +JAG35yxcvdf2zYbGwf/BWpOwMZmzDP06IxgpAEfPBj7g83Fjj6pn2MM+dQIomFB+ +/m6HRiJE +-----END CERTIFICATE----- \ No newline at end of file diff --git a/controllers/operator/testdata/certificates/just_key b/controllers/operator/testdata/certificates/just_key new file mode 100644 index 000000000..82d723a15 --- /dev/null +++ b/controllers/operator/testdata/certificates/just_key @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQCqpMIXEDT70SyE +8yDdRW6HdZaSJ9lbYaxiGCOidNoe5gFvnFCyBe6inLHNRZ6qjGOLy/V9F2NvpQbZ +SNy+/Rp+KUZkLoI6r7qSShUIlWwMmTmYHPXdzHn8q0U5s6X/kAVUOqS4PIw6gIU7 +m4zzFifPjluUUVvgt1bvMI4ELkA6w6dsROP49hc1uhvuPoKVydibazjmtSCFBgxS +6txFHlPATFylDWSEynq2sW5104ixLf/0KmxUulqKEcRkQVhHBjOkKneZL9GwyaSY +5R1NkKEFaGQ9qf3Ff58lbOMeKV2pWevHH7jJA/lEfDPWFB8tEDr3ydwplnlrGcch +Dp5hhnP+LteX1ZJbZQnHTHwzHQxgT6OS42W4sgiLUIylVPcbx3q8x7w+2TzOD/Fv +eXPeMVZgpjuCSIQi1d3J004mOjOAh0mYJZXYGXUmzI2FHP35htS8R5Ueplrq86ND +2mSa4sfX6AStdVt84tl+/fWTa6P+Muru6+HqrgKiPc3ZXKAPj7LZ9CenYFcChOgv +VhGToQGTaIlibZAFswOv/L53Lg3kxJ0YeNYDVnNJdSHptr6yfMyyLwivjczFAWAi +5zPqimoKdGPzxzL3bnYDGxbSzYmb6lSW7QJHVv7g4K+EifncTU2Q3PvnJS6HcnLN +pvFDauY9ZEn/Ozq5Fuvi1io54Zc9EwIDAQABAoICAEMUFP+/7TP328o/UHHqszIo +dRHq/DRBxuOgnZFk4cE3pOTcy5PPZSki83m/nkloelEf0dZkdUAT3QdY7v1cvSdO +zk7fQW4UWgDbgj0nj5u8N7ml2LhhgqpiIQo3pk85q/6aNtn9Yxo0Hyt5UATWdrvO +OA2rlbRWHaRUr97Q14rCEnQq+HqLMkB6cjRK+kYrXCxsD6gRF0FzSTDnBcNd0opK ++jgfdZ4FgguC3+sNRjRv4qd2bbM4thKEPXEzhqIUvAQSdYUQGRuniD5aAhTVf5aC +nLTot8sFCehKT1Ux6ZGCuX5C5/6Mw1W6hR3oNwEd2jBBd3wZnI0PSwmhl3y6v6lM +pmMmO9Tl4fG7bVlRJsktxLsCDanBSRaqn1lsbjmyic3Q82H43VebZV37e/JOYUkH +iXd1gX6a3O/x6om2IJmt48Hm71TJGxLh+NeyEtLa6eOupOK+56qU1ByJB1S/sBz5 +IkbWMaKRXu86XOK+mdnI2jURJ7W14N2zCslE6IkU7Ehx9Bg4qxvyR6/z8B7IzYzh +bWj+ilKjQjoSphGh9sDE3ZXuqIa1HP9U/n5r+03ulCPwuoGwZtwL9QCXk05Iedie +1a2rRSy8QKGVk4mHDRtgFP61jtj00u5XMeGvVMVtEXcs6UlRfK6q9K994aeoPTL9 +IyhygaqFcmrpff2QmVLBAoIBAQDBGmIa4iuMwKB3KLVSiddD9Un+5eOW3gvpmDS+ +v+Y0zyFe5QebV6UVXTXIIsVKPtXdKW6yaCGHAWwBQRemMHX+bNJCigyRKz9HrENu +fZxP80mn5Ev+LaQOjZUMCiu0hNF/dSBoxpk1NV3aKEs3re0Xv2PKvnHMtws7DgeI +ePpJ3HRnEwsHY5kPQnL9DFcwX3Nq9wcgKmM0VCItjhJZiJlHqJiM9f15RgUlThXC +tCON1EPDPvyRfu27RKIggcEQ7SEwLEYlB05EUPp5rEUaM6UeNsZvm/DzfpH6NkjZ +SSDU/mx+v36QVhareYwRJpE1tEIezLluGdSld4LQHyDoFlQDAoIBAQDiOZ/kJ/AS +QCHzU1MyEtGpGwJKoFk9j1cKXFJrfVME2k7Ys2nCdo4mtcJmJzRYhK1rBVPFPdmO +V/nnfZGVHypE1qdfgZKRiBdJTLhILwePHWcDDmWW16nxcIjGfAEMJik8TuuWER2a +B7uwWfDcXpn5FfsRRW28FcE5emVWm+x/CrZCDMdO12xElOFMM2qohC15tAoOlNMI +KsfknK+OlFUXVagqDpgzl8nOoHV3aaM00+ChL32nam97hNnTrMHzrGZUCM9PlbCV +WkIvKwEvEQQfAhoUIV5muNGm0G68LFKYYJbXBuehNQ2MfX6xWJAf4a1rtL7rDtYa +UQ4VQg8moQ2xAoIBACT4Kx4gfNv2qQIHLie+MhNVq7P8SUVB/5/aPwbh8G3d1fK4 +AGvSLM3ZSYmmdoUPYJx16TaIzxpswEPBNYjgsEZkiSCqE1vbnsLXDRXjQIDiABD/ +mTjxff43RvjGHbXy07UGNI06sGxKakxw+G2Rg9nPD4jqSxk5VhIZToHnP2vSpApz +z+G7RLtyKled/DdLnuo0nw2eb9292clE8OhpSYc5lPMvyTZlnGiW+X2MRV5K7Co+ +LdahKVx3+F4m2VKnQ3pYj5lZO7fClSGkRJqOlqchL36AqXHEoqf3qpzG7l041Iaz +nMR/ZtmvbIyACL7yYtJIuZuFoHuJVOcJfqBQXgcCggEBANR/PGmb+i2qgDmH84X9 +l2M5M5XVuP3SPvhEcEb3mZvdVGLJZHZ91lkWMlyyRsE/H4Z/ooiL6GeEzAFeOfnR +JGs1FlLn6z04kGcR4agsRPVxsOl2BIcEXWWlR1Tp9jHrRqCXoUN9IEknKm4kjdLy +Kb+HniZDCSi7Zp0PE1GfdS6AaWLxjeXJBLIHBvoE8hMI1Y6URz4bHX92b/2WEHHl +c2hP1X5r5xvPYIjuwGhCmkNtIntFmMpBeCaWS+ZBSI4TSqt0+wbOnOgtuC2GP75u +RWi7GLQABCSJRqVi9CFdoNfxIr8ohTswEmH9H5yGjBrmaXfad9tkPEjMCmZ9fq3S +aoECggEBAIzguZBBHohHaXPTMYC06M80YmMsV6kuDzA6dwCceMKDcQf+CNiwTOI9 +iv5zVWCkSpRlsYgbYgbsnZ7OV59mr83w9V7vsJB+fFWAFCDj9DcUz7xUYqFhxIk+ +qXUbJKOwW5icau5C5HQhlJAG9PmWrz0Pi+U4fofjWVHZwB3nUuPIiAigRLNEDXia +lZeZ2ZwDE7tfbyVhZageUMhFNsGJEV9S0aECq8mgHjHZUy7s3dwdCMeIsfqqJJk3 +FsaDbij2x09H2GqqtGbko3M/SVN5oCpAnkQpUfq767e27t4R3XuYtmPSEv73pCDh +8Ah6kGlS8cImcSWBf7/tAei7L5xLEqo= +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/controllers/operator/testdata/certificates/key_then_certificate b/controllers/operator/testdata/certificates/key_then_certificate new file mode 100644 index 000000000..f44aa0696 --- /dev/null +++ b/controllers/operator/testdata/certificates/key_then_certificate @@ -0,0 +1,79 @@ +-----BEGIN PRIVATE KEY----- +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 +8Ah6kGlS8cImcSWBf7/tAei7L5xLEqo= +-----END PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIEgjCCA2qgAwIBAgIRAJm5VGPguqlg4byYvf0RVFEwDQYJKoZIhvcNAQELBQAw +FTETMBEGA1UEAxMKa3ViZXJuZXRlczAeFw0yMDA0MDMwODMyNDVaFw0yMTA0MDMw +ODMyNDVaMIGLMQswCQYDVQQGEwJVUzELMAkGA1UECBMCTlkxCzAJBgNVBAcTAk5Z +MR0wGwYDVQQKExRtbXMtYXV0b21hdGlvbi1hZ2VudDEkMCIGA1UECxMbTW9uZ29E +QiBLdWJlcm5ldGVzIE9wZXJhdG9yMR0wGwYDVQQDExRtbXMtYXV0b21hdGlvbi1h +Z2VudDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAKqkwhcQNPvRLITz +IN1Fbod1lpIn2VthrGIYI6J02h7mAW+cULIF7qKcsc1FnqqMY4vL9X0XY2+lBtlI +3L79Gn4pRmQugjqvupJKFQiVbAyZOZgc9d3MefyrRTmzpf+QBVQ6pLg8jDqAhTub +jPMWJ8+OW5RRW+C3Vu8wjgQuQDrDp2xE4/j2FzW6G+4+gpXJ2JtrOOa1IIUGDFLq +3EUeU8BMXKUNZITKeraxbnXTiLEt//QqbFS6WooRxGRBWEcGM6Qqd5kv0bDJpJjl +HU2QoQVoZD2p/cV/nyVs4x4pXalZ68cfuMkD+UR8M9YUHy0QOvfJ3CmWeWsZxyEO +nmGGc/4u15fVkltlCcdMfDMdDGBPo5LjZbiyCItQjKVU9xvHerzHvD7ZPM4P8W95 +c94xVmCmO4JIhCLV3cnTTiY6M4CHSZglldgZdSbMjYUc/fmG1LxHlR6mWurzo0Pa +ZJrix9foBK11W3zi2X799ZNro/4y6u7r4equAqI9zdlcoA+Pstn0J6dgVwKE6C9W +EZOhAZNoiWJtkAWzA6/8vncuDeTEnRh41gNWc0l1Iem2vrJ8zLIvCK+NzMUBYCLn +M+qKagp0Y/PHMvdudgMbFtLNiZvqVJbtAkdW/uDgr4SJ+dxNTZDc++clLodycs2m +8UNq5j1kSf87OrkW6+LWKjnhlz0TAgMBAAGjVjBUMA4GA1UdDwEB/wQEAwIFoDAT +BgNVHSUEDDAKBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB8GA1UdEQQYMBaCFG1t +cy1hdXRvbWF0aW9uLWFnZW50MA0GCSqGSIb3DQEBCwUAA4IBAQBSIgTFY0F3dc0X +4CZbseynPDd/BRwgQlC5wXsbD6kYmnDhdpPBstlWjj4l6HtKvVsVsZxcghT9SI0l +H1m4imhh/scfVqjkBTTPikyJTfgUnN1RIJlDN+XzRz4HxvP1qVIdTlNfDW3avAvG +Cb2iHwEwH3Xv+s+3m4/FsGm/rtRTDerafkYrtCdCyY77k7/tquFRn68mvZnthiwn +zv3iFWHtdg7idbFYxyCmLXiBNzw54cEqdvehPV9UwbnupM/KEDZTL2Edcxl5gxWV +JAG35yxcvdf2zYbGwf/BWpOwMZmzDP06IxgpAEfPBj7g83Fjj6pn2MM+dQIomFB+ +/m6HRiJE +-----END CERTIFICATE----- \ No newline at end of file diff --git a/controllers/operator/testdata/version_manifest.json b/controllers/operator/testdata/version_manifest.json new file mode 100644 index 000000000..a5a8150be --- /dev/null +++ b/controllers/operator/testdata/version_manifest.json @@ -0,0 +1,192 @@ +{ + "updated": 1576540800042, + "versions": [ + { + "builds": [ + { + "architecture": "amd64", + "gitVersion": "1c1c76aeca21c5983dc178920f5052c298db616c", + "platform": "linux", + "url": "/linux/mongodb-linux-x86_64-2.6.0.tgz" + }, + { + "architecture": "amd64", + "gitVersion": "1c1c76aeca21c5983dc178920f5052c298db616c", + "platform": "osx", + "url": "/osx/mongodb-osx-x86_64-2.6.0.tgz" + }, + { + "architecture": "amd64", + "gitVersion": "1c1c76aeca21c5983dc178920f5052c298db616c", + "platform": "windows", + "url": "/win32/mongodb-win32-x86_64-2.6.0.zip" + }, + { + "architecture": "amd64", + "gitVersion": "1c1c76aeca21c5983dc178920f5052c298db616c", + "platform": "windows", + "url": "/win32/mongodb-win32-x86_64-2008plus-2.6.0.zip", + "win2008plus": true + } + ], + "name": "2.6.0" + }, + { + "builds": [ + { + "architecture": "amd64", + "flavor": "ubuntu", + "gitVersion": "1c1c76aeca21c5983dc178920f5052c298db616c modules: enterprise", + "maxOsVersion": "13.04", + "minOsVersion": "12.04", + "platform": "linux", + "url": "/linux/mongodb-linux-x86_64-enterprise-ubuntu1204-2.6.0.tgz" + }, + { + "architecture": "amd64", + "gitVersion": "1c1c76aeca21c5983dc178920f5052c298db616c modules: enterprise", + "platform": "windows", + "url": "/win32/mongodb-win32-x86_64-enterprise-windows-64-2.6.0.zip", + "win2008plus": true, + "winVCRedistDll": "msvcr100.dll", + "winVCRedistOptions": [ + "/q", + "/norestart" + ], + "winVCRedistUrl": "http://download.microsoft.com/download/1/6/5/165255E7-1014-4D0A-B094-B6A430A6BFFC/vcredist_x64.exe", + "winVCRedistVersion": "10.0.40219.325" + } + ], + "name": "2.6.0-ent" + }, + { + "builds": [ + { + "architecture": "amd64", + "flavor": "ubuntu", + "gitVersion": "a57d8e71e6998a2d0afde7edc11bd23e5661c915", + "maxOsVersion": "15.04", + "minOsVersion": "14.04", + "platform": "linux", + "url": "/linux/mongodb-linux-x86_64-ubuntu1404-3.6.0.tgz" + }, + { + "architecture": "amd64", + "flavor": "ubuntu", + "gitVersion": "a57d8e71e6998a2d0afde7edc11bd23e5661c915", + "maxOsVersion": "17.04", + "minOsVersion": "16.04", + "platform": "linux", + "url": "/linux/mongodb-linux-x86_64-ubuntu1604-3.6.0.tgz" + } + ], + "name": "3.6.0" + }, + { + "builds": [ + { + "architecture": "amd64", + "flavor": "amazon", + "gitVersion": "a57d8e71e6998a2d0afde7edc11bd23e5661c915", + "maxOsVersion": "", + "minOsVersion": "2013.03", + "modules": [ + "enterprise" + ], + "platform": "linux", + "url": "/linux/mongodb-linux-x86_64-enterprise-amzn64-3.6.0.tgz" + }, + { + "architecture": "amd64", + "flavor": "rhel", + "gitVersion": "a57d8e71e6998a2d0afde7edc11bd23e5661c915", + "maxOsVersion": "7.0", + "minOsVersion": "6.2", + "modules": [ + "enterprise" + ], + "platform": "linux", + "url": "/linux/mongodb-linux-x86_64-enterprise-rhel62-3.6.0.tgz" + } + ], + "name": "3.6.0-ent" + }, + { + "builds": [ + { + "architecture": "ppc64le", + "gitVersion": "a0bbbff6ada159e19298d37946ac8dc4b497eadf", + "platform": "linux", + "url": "/linux/mongodb-linux-ppc64le-enterprise-rhel71-4.2.2.tgz", + "flavor": "rhel", + "maxOsVersion": "8.0", + "minOsVersion": "7.0", + "modules": [ + "enterprise" + ] + }, + { + "architecture": "amd64", + "gitVersion": "a0bbbff6ada159e19298d37946ac8dc4b497eadf", + "platform": "linux", + "url": "/linux/mongodb-linux-x86_64-enterprise-rhel62-4.2.2.tgz", + "flavor": "rhel", + "maxOsVersion": "7.0", + "minOsVersion": "6.2", + "modules": [ + "enterprise" + ] + }, + { + "architecture": "s390x", + "gitVersion": "a0bbbff6ada159e19298d37946ac8dc4b497eadf", + "platform": "linux", + "url": "/linux/mongodb-linux-s390x-enterprise-rhel72-4.2.2.tgz", + "flavor": "rhel", + "maxOsVersion": "8.0", + "minOsVersion": "7.0", + "modules": [ + "enterprise" + ] + }, + { + "architecture": "amd64", + "gitVersion": "a0bbbff6ada159e19298d37946ac8dc4b497eadf", + "platform": "linux", + "url": "/linux/mongodb-linux-x86_64-enterprise-ubuntu1604-4.2.2.tgz", + "flavor": "ubuntu", + "maxOsVersion": "17.04", + "minOsVersion": "16.04", + "modules": [ + "enterprise" + ] + }, + { + "architecture": "amd64", + "gitVersion": "a0bbbff6ada159e19298d37946ac8dc4b497eadf", + "platform": "linux", + "url": "/linux/mongodb-linux-x86_64-enterprise-suse12-4.2.2.tgz", + "flavor": "suse", + "maxOsVersion": "13", + "minOsVersion": "12", + "modules": [ + "enterprise" + ] + }, + { + "architecture": "amd64", + "gitVersion": "a0bbbff6ada159e19298d37946ac8dc4b497eadf", + "platform": "linux", + "url": "/linux/mongodb-linux-x86_64-enterprise-debian92-4.2.2.tgz", + "flavor": "debian", + "maxOsVersion": "10.0", + "minOsVersion": "9.1", + "modules": [ + "enterprise" + ] + } + ], + "name": "4.2.2-ent" + } + ] +} diff --git a/controllers/operator/watch/config_change_handler.go b/controllers/operator/watch/config_change_handler.go new file mode 100644 index 000000000..38ed58d96 --- /dev/null +++ b/controllers/operator/watch/config_change_handler.go @@ -0,0 +1,133 @@ +package watch + +import ( + "fmt" + "reflect" + + "github.com/google/go-cmp/cmp" + "sigs.k8s.io/controller-runtime/pkg/client" + + "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/workqueue" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// Type is an enum for all kubernetes types watched by controller for changes for configuration +type Type string + +const ( + ConfigMap Type = "ConfigMap" + Secret Type = "Secret" + MongoDB Type = "MongoDB" +) + +// the object watched by controller. Includes its type and namespace+name +type Object struct { + ResourceType Type + Resource types.NamespacedName +} + +func (w Object) String() string { + return fmt.Sprintf("%s (%s)", w.Resource, w.ResourceType) +} + +// ResourcesHandler is a special implementation of 'handler.EventHandler' that checks if the event for +// K8s Resource must trigger reconciliation for any Operator managed Resource (MongoDB, MongoDBOpsManager). This is +// done via consulting the 'TrackedResources' map. The map is stored in relevant reconciler which puts pairs +// [K8s_resource_name -> operator_managed_resource_name] there as +// soon as reconciliation happens for the Resource +type ResourcesHandler struct { + ResourceType Type + TrackedResources map[Object][]types.NamespacedName +} + +// Note that we implement Create in addition to Update to be able to handle cases when config map or secret is deleted +// and then created again. +func (c *ResourcesHandler) Create(e event.CreateEvent, q workqueue.RateLimitingInterface) { + c.doHandle(e.Object.GetNamespace(), e.Object.GetName(), q) +} + +func (c *ResourcesHandler) Update(e event.UpdateEvent, q workqueue.RateLimitingInterface) { + if !shouldHandleUpdate(e) { + return + } + c.doHandle(e.ObjectOld.GetNamespace(), e.ObjectOld.GetName(), q) +} + +// shouldHandleUpdate return true if the update event must be handled. This shouldn't happen if data for watched +// ConfigMap or Secret hasn't changed +func shouldHandleUpdate(e event.UpdateEvent) bool { + switch v := e.ObjectOld.(type) { + case *corev1.ConfigMap: + return !reflect.DeepEqual(v.Data, e.ObjectNew.(*corev1.ConfigMap).Data) + case *corev1.Secret: + return !reflect.DeepEqual(v.Data, e.ObjectNew.(*corev1.Secret).Data) + } + return true +} + +func (c *ResourcesHandler) doHandle(namespace, name string, q workqueue.RateLimitingInterface) { + + configMapOrSecret := Object{ + ResourceType: c.ResourceType, + Resource: types.NamespacedName{Name: name, Namespace: namespace}, + } + + for _, v := range c.TrackedResources[configMapOrSecret] { + zap.S().Infof("%s has been modified -> triggering reconciliation for dependent Resource %s", configMapOrSecret, v) + q.Add(reconcile.Request{NamespacedName: v}) + } + +} + +// Seems we don't need to react on config map/secret removal.. +func (c *ResourcesHandler) Delete(event.DeleteEvent, workqueue.RateLimitingInterface) {} + +func (c *ResourcesHandler) Generic(event.GenericEvent, workqueue.RateLimitingInterface) {} + +// ConfigMapEventHandler is an EventHandler implementation that is used to watch for events on a given ConfigMap and ConfigMapNamespace +// The handler will force a panic on Update and Delete. +// As of right now it is only used to watch for events for the configmap pertaining the member list of multi-cluster. +type ConfigMapEventHandler struct { + ConfigMapName string + ConfigMapNamespace string +} + +func (m ConfigMapEventHandler) Create(e event.CreateEvent, _ workqueue.RateLimitingInterface) { + return +} + +func (m ConfigMapEventHandler) Update(e event.UpdateEvent, _ workqueue.RateLimitingInterface) { + if m.isMemberListCM(e.ObjectOld) { + switch v := e.ObjectOld.(type) { + case *corev1.ConfigMap: + changelog := cmp.Diff(v.Data, e.ObjectNew.(*corev1.ConfigMap).Data) + errMsg := fmt.Sprintf("%s/%s has changed! Will kill the pod to source the changes! Changelog: %s", m.ConfigMapNamespace, m.ConfigMapName, changelog) + panic(errMsg) + } + } + +} + +func (m ConfigMapEventHandler) Delete(e event.DeleteEvent, _ workqueue.RateLimitingInterface) { + if m.isMemberListCM(e.Object) { + errMsg := fmt.Sprintf("%s/%s has been deleted! Note we will need the configmap otherwise the operator will not work", m.ConfigMapNamespace, m.ConfigMapName) + panic(errMsg) + } +} + +func (m ConfigMapEventHandler) Generic(e event.GenericEvent, _ workqueue.RateLimitingInterface) { + return +} + +func (m ConfigMapEventHandler) isMemberListCM(o client.Object) bool { + name := o.GetName() + ns := o.GetNamespace() + if name == m.ConfigMapName && ns == m.ConfigMapNamespace { + return true + } + return false +} diff --git a/controllers/operator/watch/config_change_handler_test.go b/controllers/operator/watch/config_change_handler_test.go new file mode 100644 index 000000000..9e459f9a8 --- /dev/null +++ b/controllers/operator/watch/config_change_handler_test.go @@ -0,0 +1,67 @@ +package watch + +import ( + "testing" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/event" +) + +func TestShouldHandleUpdate(t *testing.T) { + t.Run("Update shouldn't happen if ConfigMaps data hasn't changed", func(t *testing.T) { + oldObj := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + Namespace: "ns", + }, + Data: map[string]string{"testKey": "testValue"}, + } + newObj := oldObj.DeepCopy() + newObj.ObjectMeta.ResourceVersion = "4243" + + assert.False(t, shouldHandleUpdate(event.UpdateEvent{ObjectOld: oldObj, ObjectNew: newObj})) + }) + t.Run("Update should happen if the data has changed for ConfigMap", func(t *testing.T) { + oldObj := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + Namespace: "ns", + }, + Data: map[string]string{"testKey": "testValue"}, + } + newObj := oldObj.DeepCopy() + newObj.ObjectMeta.ResourceVersion = "4243" + newObj.Data["secondKey"] = "secondValue" + + assert.True(t, shouldHandleUpdate(event.UpdateEvent{ObjectOld: oldObj, ObjectNew: newObj})) + }) + t.Run("Update shouldn't happen if Secrets data hasn't changed", func(t *testing.T) { + oldObj := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + Namespace: "ns", + }, + Data: map[string][]byte{"testKey": []byte("testValue")}, + } + newObj := oldObj.DeepCopy() + newObj.ObjectMeta.ResourceVersion = "4243" + + assert.False(t, shouldHandleUpdate(event.UpdateEvent{ObjectOld: oldObj, ObjectNew: newObj})) + }) + t.Run("Update should happen if the data has changed for Secret", func(t *testing.T) { + oldObj := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + Namespace: "ns", + }, + Data: map[string][]byte{"testKey": []byte("testValue")}, + } + newObj := oldObj.DeepCopy() + newObj.ObjectMeta.ResourceVersion = "4243" + newObj.Data["secondKey"] = []byte("secondValue") + + assert.True(t, shouldHandleUpdate(event.UpdateEvent{ObjectOld: oldObj, ObjectNew: newObj})) + }) +} diff --git a/controllers/operator/watch/predicates.go b/controllers/operator/watch/predicates.go new file mode 100644 index 000000000..276e34ace --- /dev/null +++ b/controllers/operator/watch/predicates.go @@ -0,0 +1,179 @@ +package watch + +import ( + "reflect" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/vault" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + omv1 "github.com/10gen/ops-manager-kubernetes/api/v1/om" + userv1 "github.com/10gen/ops-manager-kubernetes/api/v1/user" + "github.com/10gen/ops-manager-kubernetes/pkg/handler" + appsv1 "k8s.io/api/apps/v1" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +func PredicatesForUser() predicate.Funcs { + return predicate.Funcs{ + // don't update users on status changes + UpdateFunc: func(e event.UpdateEvent) bool { + oldResource := e.ObjectOld.(*userv1.MongoDBUser) + newResource := e.ObjectNew.(*userv1.MongoDBUser) + + oldSpecAnnotation := oldResource.GetAnnotations()[util.LastAchievedSpec] + newSpecAnnotation := newResource.GetAnnotations()[util.LastAchievedSpec] + + // don't handle an update to just the previous spec annotation if they are not the same. + // this prevents the operator triggering reconciliations on resource that it is updating itself. + if !reflect.DeepEqual(oldSpecAnnotation, newSpecAnnotation) { + return false + } + + return reflect.DeepEqual(oldResource.GetStatus(), newResource.GetStatus()) + }, + } +} + +func PredicatesForOpsManager() predicate.Funcs { + return predicate.Funcs{ + // don't update ops manager on status changes + UpdateFunc: func(e event.UpdateEvent) bool { + oldResource := e.ObjectOld.(*omv1.MongoDBOpsManager) + newResource := e.ObjectNew.(*omv1.MongoDBOpsManager) + + oldSpecAnnotation := oldResource.GetAnnotations()[util.LastAchievedSpec] + newSpecAnnotation := newResource.GetAnnotations()[util.LastAchievedSpec] + + // don't handle an update to just the previous spec annotation if they are not the same. + // this prevents the operator triggering reconciliations on resource that it is updating itself. + if !reflect.DeepEqual(oldSpecAnnotation, newSpecAnnotation) { + return false + } + // check if any one of the vault annotations are different in revision + if vault.IsVaultSecretBackend() { + + for _, e := range oldResource.GetSecretsMountedIntoPod() { + if oldResource.GetAnnotations()[e] != newResource.GetAnnotations()[e] { + return true + } + } + + for _, e := range oldResource.Spec.AppDB.GetSecretsMountedIntoPod() { + if oldResource.GetAnnotations()[e] != newResource.GetAnnotations()[e] { + return true + } + } + return false + } + + return reflect.DeepEqual(oldResource.GetStatus(), newResource.GetStatus()) + }, + } +} + +func PredicatesForMongoDB(resourceType mdbv1.ResourceType) predicate.Funcs { + return predicate.Funcs{ + CreateFunc: func(createEvent event.CreateEvent) bool { + resource := createEvent.Object.(*mdbv1.MongoDB) + return resource.Spec.ResourceType == resourceType + }, + DeleteFunc: func(deleteEvent event.DeleteEvent) bool { + resource := deleteEvent.Object.(*mdbv1.MongoDB) + return resource.Spec.ResourceType == resourceType + }, + GenericFunc: func(genericEvent event.GenericEvent) bool { + resource := genericEvent.Object.(*mdbv1.MongoDB) + return resource.Spec.ResourceType == resourceType + }, + UpdateFunc: func(e event.UpdateEvent) bool { + oldResource := e.ObjectOld.(*mdbv1.MongoDB) + newResource := e.ObjectNew.(*mdbv1.MongoDB) + + oldSpecAnnotation := oldResource.GetAnnotations()[util.LastAchievedSpec] + newSpecAnnotation := newResource.GetAnnotations()[util.LastAchievedSpec] + + // don't handle an update to just the previous spec annotation if they are not the same. + // this prevents the operator triggering reconciliations on resource that it is updating itself. + if !reflect.DeepEqual(oldSpecAnnotation, newSpecAnnotation) { + return false + } + + // check if any one of the vault annotations are different in revision + if vault.IsVaultSecretBackend() { + vaultReconcile := false + credentialsAnnotation := newResource.Spec.Credentials + if oldResource.GetAnnotations()[credentialsAnnotation] != newResource.GetAnnotations()[credentialsAnnotation] { + vaultReconcile = true + } + for _, e := range oldResource.GetSecretsMountedIntoDBPod() { + if oldResource.GetAnnotations()[e] != newResource.GetAnnotations()[e] { + vaultReconcile = true + } + } + return newResource.Spec.ResourceType == resourceType && vaultReconcile + } + + // ignore events that aren't related to our target Resource and any changes done to the status + // (it's the controller that has made those changes, not user) + return newResource.Spec.ResourceType == resourceType && + reflect.DeepEqual(oldResource.GetStatus(), newResource.GetStatus()) + }} +} + +func PredicatesForStatefulSet() predicate.Funcs { + return predicate.Funcs{ + UpdateFunc: func(e event.UpdateEvent) bool { + oldSts := e.ObjectOld.(*appsv1.StatefulSet) + newSts := e.ObjectNew.(*appsv1.StatefulSet) + + val, ok := newSts.Annotations["type"] + + if ok && val == "Replicaset" { + if !reflect.DeepEqual(oldSts.Status, newSts.Status) && (newSts.Status.ReadyReplicas < *newSts.Spec.Replicas) { + return true + } + } + return false + }, + DeleteFunc: func(e event.DeleteEvent) bool { + return false + }, + GenericFunc: func(e event.GenericEvent) bool { + return false + }, + CreateFunc: func(e event.CreateEvent) bool { + return false + }, + } +} + +// PredicatesForMultiStatefulSet is the predicate functions for the custom Statefulset Event +// handler used for Multicluster reconciler +func PredicatesForMultiStatefulSet() predicate.Funcs { + return predicate.Funcs{ + UpdateFunc: func(e event.UpdateEvent) bool { + oldSts := e.ObjectOld.(*appsv1.StatefulSet) + newSts := e.ObjectNew.(*appsv1.StatefulSet) + + // check if it is owned by MultiCluster CR first + if _, ok := newSts.Annotations[handler.MongoDBMultiResourceAnnotation]; !ok { + return false + } + + return !reflect.DeepEqual(oldSts.Status, newSts.Status) + }, + DeleteFunc: func(e event.DeleteEvent) bool { + _, ok := e.Object.GetAnnotations()[handler.MongoDBMultiResourceAnnotation] + return ok + + }, + GenericFunc: func(e event.GenericEvent) bool { + return false + }, + CreateFunc: func(e event.CreateEvent) bool { + return false + }, + } +} diff --git a/controllers/operator/watch/predicates_test.go b/controllers/operator/watch/predicates_test.go new file mode 100644 index 000000000..f5224959c --- /dev/null +++ b/controllers/operator/watch/predicates_test.go @@ -0,0 +1,90 @@ +package watch + +import ( + "testing" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + omv1 "github.com/10gen/ops-manager-kubernetes/api/v1/om" + "github.com/10gen/ops-manager-kubernetes/api/v1/status" + "github.com/10gen/ops-manager-kubernetes/api/v1/user" + "github.com/stretchr/testify/assert" + "sigs.k8s.io/controller-runtime/pkg/event" +) + +func TestPredicatesForUser(t *testing.T) { + t.Run("No reconciliation for MongoDBUser if statuses are not equal", func(t *testing.T) { + oldUser := &user.MongoDBUser{ + Status: user.MongoDBUserStatus{}, + } + newUser := oldUser.DeepCopy() + newUser.Status.Phase = status.PhaseReconciling + assert.False(t, PredicatesForUser().Update(event.UpdateEvent{ObjectOld: oldUser, ObjectNew: newUser})) + }) + t.Run("Reconciliation happens for MongoDBUser if statuses are equal", func(t *testing.T) { + oldUser := &user.MongoDBUser{ + Status: user.MongoDBUserStatus{}, + } + newUser := oldUser.DeepCopy() + newUser.Spec.Username = "test" + assert.True(t, PredicatesForUser().Update(event.UpdateEvent{ObjectOld: oldUser, ObjectNew: newUser})) + }) +} + +func TestPredicatesForOpsManager(t *testing.T) { + t.Run("No reconciliation for MongoDBOpsManager if statuses are not equal", func(t *testing.T) { + oldOm := omv1.NewOpsManagerBuilder().Build() + newOm := oldOm.DeepCopy() + newOm.Spec.Replicas = 2 + newOm.Status.OpsManagerStatus = omv1.OpsManagerStatus{Warnings: []status.Warning{"warning"}} + assert.False(t, PredicatesForOpsManager().Update(event.UpdateEvent{ObjectOld: &oldOm, ObjectNew: newOm})) + }) + t.Run("Reconciliation happens for MongoDBOpsManager if statuses are equal", func(t *testing.T) { + oldOm := omv1.NewOpsManagerBuilder().Build() + newOm := oldOm.DeepCopy() + newOm.Spec.Replicas = 2 + assert.True(t, PredicatesForOpsManager().Update(event.UpdateEvent{ObjectOld: &oldOm, ObjectNew: newOm})) + }) +} + +func TestPredicatesForMongoDB(t *testing.T) { + t.Run("Creation event is handled", func(t *testing.T) { + standalone := mdbv1.NewStandaloneBuilder().Build() + assert.True(t, PredicatesForMongoDB(mdbv1.Standalone).Create(event.CreateEvent{Object: standalone})) + }) + t.Run("Creation event is not handled", func(t *testing.T) { + rs := mdbv1.NewReplicaSetBuilder().Build() + assert.False(t, PredicatesForMongoDB(mdbv1.Standalone).Create(event.CreateEvent{Object: rs})) + }) + t.Run("Delete event is handled", func(t *testing.T) { + sc := mdbv1.NewClusterBuilder().Build() + assert.True(t, PredicatesForMongoDB(mdbv1.ShardedCluster).Delete(event.DeleteEvent{Object: sc})) + }) + t.Run("Delete event is not handled", func(t *testing.T) { + rs := mdbv1.NewReplicaSetBuilder().Build() + assert.False(t, PredicatesForMongoDB(mdbv1.ShardedCluster).Delete(event.DeleteEvent{Object: rs})) + }) + t.Run("Update event is handled, statuses not changed", func(t *testing.T) { + oldMdb := mdbv1.NewStandaloneBuilder().Build() + newMdb := oldMdb.DeepCopy() + newMdb.Spec.Version = "4.2.0" + assert.True(t, PredicatesForMongoDB(mdbv1.Standalone).Update( + event.UpdateEvent{ObjectOld: oldMdb, ObjectNew: newMdb}), + ) + }) + t.Run("Update event is not handled, statuses changed", func(t *testing.T) { + oldMdb := mdbv1.NewStandaloneBuilder().Build() + newMdb := oldMdb.DeepCopy() + newMdb.Status.Version = "4.2.0" + assert.False(t, PredicatesForMongoDB(mdbv1.Standalone).Update( + event.UpdateEvent{ObjectOld: oldMdb, ObjectNew: newMdb}), + ) + }) + t.Run("Update event is not handled, different types", func(t *testing.T) { + oldMdb := mdbv1.NewStandaloneBuilder().Build() + newMdb := oldMdb.DeepCopy() + newMdb.Spec.Version = "4.2.0" + assert.False(t, PredicatesForMongoDB(mdbv1.ShardedCluster).Update( + event.UpdateEvent{ObjectOld: oldMdb, ObjectNew: newMdb}), + ) + }) +} diff --git a/controllers/operator/watch/resource_watcher.go b/controllers/operator/watch/resource_watcher.go new file mode 100644 index 000000000..69e3ed88d --- /dev/null +++ b/controllers/operator/watch/resource_watcher.go @@ -0,0 +1,133 @@ +package watch + +import ( + "go.uber.org/zap" + "k8s.io/apimachinery/pkg/types" +) + +func NewResourceWatcher() ResourceWatcher { + return ResourceWatcher{ + WatchedResources: map[Object][]types.NamespacedName{}, + } +} + +type ResourceWatcher struct { + WatchedResources map[Object][]types.NamespacedName +} + +// RegisterWatchedMongodbResources adds the secret/configMap -> mongodb resource pair to internal reconciler map. This allows +// to start watching for the events for this secret/configMap and trigger reconciliation for all depending mongodb resources +func (r *ResourceWatcher) RegisterWatchedMongodbResources(mongodbResourceNsName types.NamespacedName, configMap string, secret string) { + defaultNamespace := mongodbResourceNsName.Namespace + + r.AddWatchedResourceIfNotAdded(configMap, defaultNamespace, ConfigMap, mongodbResourceNsName) + r.AddWatchedResourceIfNotAdded(secret, defaultNamespace, Secret, mongodbResourceNsName) +} + +// GetWatchedResourcesOfType returns all watched resources of the given type in the specified namespace. +// if the specified namespace is the zero value, resources in all namespaces will be returned. +func (r *ResourceWatcher) GetWatchedResourcesOfType(wType Type, ns string) []types.NamespacedName { + var res []types.NamespacedName + for k := range r.WatchedResources { + if k.ResourceType != wType { + continue + } + if k.Resource.Namespace == ns || ns == "" { + res = append(res, k.Resource) + } + } + return res +} + +// RegisterWatchedTLSResources adds the CA configMap and a slice of TLS secrets to the list of watched resources. +func (r *ResourceWatcher) RegisterWatchedTLSResources(mongodbResourceNsName types.NamespacedName, caConfigMap string, tlsSecrets []string) { + defaultNamespace := mongodbResourceNsName.Namespace + + if caConfigMap != "" { + r.AddWatchedResourceIfNotAdded(caConfigMap, defaultNamespace, ConfigMap, mongodbResourceNsName) + } + + for _, tlsSecret := range tlsSecrets { + r.AddWatchedResourceIfNotAdded(tlsSecret, defaultNamespace, Secret, mongodbResourceNsName) + } +} + +// RemoveDependentWatchedResources stops watching resources related to the input resource +func (r *ResourceWatcher) RemoveDependentWatchedResources(resourceNsName types.NamespacedName) { + r.RemoveAllDependentWatchedResources(resourceNsName.Namespace, resourceNsName) +} + +// AddWatchedResourceIfNotAdded adds the given resource to the list of watched +// resources. A watched resource is a resource that, when changed, will trigger +// a reconciliation for its dependent resource. +func (r *ResourceWatcher) AddWatchedResourceIfNotAdded(name, namespace string, + wType Type, dependentResourceNsName types.NamespacedName) { + key := Object{ + ResourceType: wType, + Resource: types.NamespacedName{ + Name: name, + Namespace: namespace, + }, + } + if _, ok := r.WatchedResources[key]; !ok { + r.WatchedResources[key] = make([]types.NamespacedName, 0) + } + found := false + for _, v := range r.WatchedResources[key] { + if v == dependentResourceNsName { + found = true + } + } + if !found { + r.WatchedResources[key] = append(r.WatchedResources[key], dependentResourceNsName) + zap.S().Debugf("Watching %s to trigger reconciliation for %s", key, dependentResourceNsName) + } +} + +// RemoveWatchedResources stop watching resources with input namespace and watched type, if any +func (r *ResourceWatcher) RemoveWatchedResources(namespace string, wType Type, dependentResourceNsName types.NamespacedName) { + for key := range r.WatchedResources { + if key.ResourceType == wType && key.Resource.Namespace == namespace { + index := -1 + for i, v := range r.WatchedResources[key] { + if v == dependentResourceNsName { + index = i + } + } + + if index == -1 { + continue + } + + zap.S().Infof("Removing %s from resources dependent on %s", dependentResourceNsName, key) + + if index == 0 { + if len(r.WatchedResources[key]) == 1 { + delete(r.WatchedResources, key) + continue + } + r.WatchedResources[key] = r.WatchedResources[key][index+1:] + continue + } + + if index == len(r.WatchedResources[key]) { + r.WatchedResources[key] = r.WatchedResources[key][:index] + continue + } + + r.WatchedResources[key] = append(r.WatchedResources[key][:index], r.WatchedResources[key][index+1:]...) + } + } +} + +// RemoveAllDependentWatchedResources stop watching resources with input namespace and dependent resource +func (r *ResourceWatcher) RemoveAllDependentWatchedResources(namespace string, dependentResourceNsName types.NamespacedName) { + watchedResourceTypes := map[Type]bool{} + for resource := range r.WatchedResources { + watchedResourceTypes[resource.ResourceType] = true + } + + for resourceType := range watchedResourceTypes { + r.RemoveWatchedResources(namespace, resourceType, dependentResourceNsName) + } +} diff --git a/controllers/operator/workflow/disabled.go b/controllers/operator/workflow/disabled.go new file mode 100644 index 000000000..6201cab38 --- /dev/null +++ b/controllers/operator/workflow/disabled.go @@ -0,0 +1,18 @@ +package workflow + +import ( + "github.com/10gen/ops-manager-kubernetes/api/v1/status" +) + +// disabledStatus indicates that the subresource is not enabled +type disabledStatus struct { + *okStatus +} + +func Disabled() *disabledStatus { + return &disabledStatus{okStatus: &okStatus{requeue: false}} +} + +func (d disabledStatus) Phase() status.Phase { + return status.PhaseDisabled +} diff --git a/controllers/operator/workflow/failed.go b/controllers/operator/workflow/failed.go new file mode 100644 index 000000000..08bac2cf3 --- /dev/null +++ b/controllers/operator/workflow/failed.go @@ -0,0 +1,82 @@ +package workflow + +import ( + "golang.org/x/xerrors" + "time" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/util/apierrors" + + "github.com/10gen/ops-manager-kubernetes/api/v1/status" + "go.uber.org/zap" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// failedStatus indicates that the reconciliation process must be suspended and CR should get "Pending" status +type failedStatus struct { + commonStatus + retryInSeconds time.Duration + // err contains error with stacktrace + err error +} + +func Failed(err error, params ...interface{}) *failedStatus { + return &failedStatus{commonStatus: newCommonStatus(err.Error(), params...), err: err, retryInSeconds: 10} +} + +func (f *failedStatus) WithWarnings(warnings []status.Warning) *failedStatus { + f.warnings = warnings + return f +} + +func (f *failedStatus) WithRetry(retryInSeconds time.Duration) *failedStatus { + f.retryInSeconds = retryInSeconds + return f +} + +func (f failedStatus) ReconcileResult() (reconcile.Result, error) { + return reconcile.Result{RequeueAfter: time.Second * f.retryInSeconds}, nil +} + +func (f failedStatus) IsOK() bool { + return false +} + +func (f failedStatus) Merge(other Status) Status { + switch v := other.(type) { + // errors are concatenated + case failedStatus: + return mergedFailed(f, v) + case invalidStatus: + return other + } + return f +} +func (f failedStatus) OnErrorPrepend(msg string) Status { + f.commonStatus.prependMsg(msg) + return f +} + +func (f failedStatus) StatusOptions() []status.Option { + // don't display any message on the MongoDB resource if the error is transient. + options := f.statusOptions() + return options +} + +func (f failedStatus) Phase() status.Phase { + if apierrors.IsTransientMessage(f.msg) { + return status.PhasePending + } + return status.PhaseFailed +} + +// Log does not take the f.msg but instead takes f.err to make sure we print the actual stack trace of the error. +func (f failedStatus) Log(log *zap.SugaredLogger) { + log.Errorf("%+v", f.err) +} + +func mergedFailed(p1, p2 failedStatus) failedStatus { + msg := p1.msg + ", " + p2.msg + p := Failed(xerrors.Errorf(msg)) + p.warnings = append(p1.warnings, p2.warnings...) + return *p +} diff --git a/controllers/operator/workflow/invalid.go b/controllers/operator/workflow/invalid.go new file mode 100644 index 000000000..ea4ca2443 --- /dev/null +++ b/controllers/operator/workflow/invalid.go @@ -0,0 +1,78 @@ +package workflow + +import ( + "github.com/10gen/ops-manager-kubernetes/api/v1/status" + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" + "go.uber.org/zap" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// invalidStatus indicates that the reconciliation process must be suspended and CR should get "Pending" status +type invalidStatus struct { + commonStatus + targetPhase status.Phase +} + +func Invalid(msg string, params ...interface{}) *invalidStatus { + return &invalidStatus{commonStatus: newCommonStatus(msg, params...), targetPhase: status.PhaseFailed} +} + +func (f *invalidStatus) WithWarnings(warnings []status.Warning) *invalidStatus { + f.warnings = warnings + return f +} + +// WithTargetPhase allows to override the default phase for "invalid" (Failed) to another one. +// Most of all it may be Pending +func (f *invalidStatus) WithTargetPhase(targetPhase status.Phase) *invalidStatus { + f.targetPhase = targetPhase + return f +} + +func (f invalidStatus) ReconcileResult() (reconcile.Result, error) { + // We don't requeue validation failures + return reconcile.Result{}, nil +} + +func (f invalidStatus) IsOK() bool { + return false +} + +func (f invalidStatus) Merge(other Status) Status { + switch v := other.(type) { + // errors are concatenated + case invalidStatus: + return mergedInvalid(f, v) + } + // Invalid spec error dominates over anything else - there's no point in retrying until the spec is fixed + return f +} +func (f invalidStatus) OnErrorPrepend(msg string) Status { + f.commonStatus.prependMsg(msg) + return f +} + +func (f invalidStatus) StatusOptions() []status.Option { + options := f.statusOptions() + // Add any specific options here + return options +} + +func (f invalidStatus) Phase() status.Phase { + return f.targetPhase +} + +func (f invalidStatus) Log(log *zap.SugaredLogger) { + log.Error(stringutil.UpperCaseFirstChar(f.msg)) +} + +func mergedInvalid(p1, p2 invalidStatus) invalidStatus { + p := Invalid(p1.msg + ", " + p2.msg) + p.warnings = append(p1.warnings, p2.warnings...) + // Choosing one of the non-empty target phases + p.targetPhase = p2.targetPhase + if p1.targetPhase != "" { + p.targetPhase = p1.targetPhase + } + return *p +} diff --git a/controllers/operator/workflow/ok.go b/controllers/operator/workflow/ok.go new file mode 100644 index 000000000..a8973381c --- /dev/null +++ b/controllers/operator/workflow/ok.go @@ -0,0 +1,59 @@ +package workflow + +import ( + "github.com/10gen/ops-manager-kubernetes/api/v1/status" + "go.uber.org/zap" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// okStatus indicates that the reconciliation process must be suspended and CR should get "Pending" status +type okStatus struct { + commonStatus + requeue bool +} + +func OK() *okStatus { + return &okStatus{} +} + +func (o *okStatus) WithWarnings(warnings []status.Warning) *okStatus { + o.warnings = warnings + return o +} + +func (o okStatus) ReconcileResult() (reconcile.Result, error) { + return reconcile.Result{Requeue: o.requeue}, nil +} + +func (o okStatus) IsOK() bool { + if o.requeue { + return false + } + return true +} + +func (o okStatus) Merge(other Status) Status { + // any other status takes precedence over OK + return other +} + +func (o okStatus) OnErrorPrepend(_ string) Status { + return o +} + +func (o okStatus) StatusOptions() []status.Option { + return o.statusOptions() +} + +func (f okStatus) Log(_ *zap.SugaredLogger) { + // Doing no logging - the reconciler will do instead +} + +func (o okStatus) Phase() status.Phase { + return status.PhaseRunning +} + +func (o *okStatus) Requeue() Status { + o.requeue = true + return o +} diff --git a/controllers/operator/workflow/pending.go b/controllers/operator/workflow/pending.go new file mode 100644 index 000000000..b8767ae77 --- /dev/null +++ b/controllers/operator/workflow/pending.go @@ -0,0 +1,79 @@ +package workflow + +import ( + "time" + + "github.com/10gen/ops-manager-kubernetes/api/v1/status" + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" + + "go.uber.org/zap" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// pendingStatus indicates that the reconciliation process must be suspended and CR should get "Pending" status +type pendingStatus struct { + commonStatus + retryInSeconds time.Duration +} + +func Pending(msg string, params ...interface{}) *pendingStatus { + return &pendingStatus{commonStatus: newCommonStatus(msg, params...), retryInSeconds: 10} +} + +func (p *pendingStatus) WithWarnings(warnings []status.Warning) *pendingStatus { + p.warnings = warnings + return p +} + +func (p *pendingStatus) WithRetry(retryInSeconds time.Duration) *pendingStatus { + p.retryInSeconds = retryInSeconds + return p +} + +func (p *pendingStatus) WithResourcesNotReady(resourcesNotReady []status.ResourceNotReady) *pendingStatus { + p.resourcesNotReady = resourcesNotReady + return p +} + +func (p pendingStatus) ReconcileResult() (reconcile.Result, error) { + return reconcile.Result{RequeueAfter: time.Second * p.retryInSeconds}, nil +} + +func (p pendingStatus) IsOK() bool { + return false +} + +func (p pendingStatus) Merge(other Status) Status { + switch v := other.(type) { + // Pending messages are just merged together + case pendingStatus: + return mergedPending(p, v) + case failedStatus: + return v + } + return p +} +func (p pendingStatus) OnErrorPrepend(msg string) Status { + p.commonStatus.prependMsg(msg) + return p +} + +func (p pendingStatus) StatusOptions() []status.Option { + options := p.statusOptions() + // Add any custom options here + return options +} + +func (p pendingStatus) Phase() status.Phase { + return status.PhasePending +} + +func (f pendingStatus) Log(log *zap.SugaredLogger) { + log.Info(stringutil.UpperCaseFirstChar(f.msg)) +} + +func mergedPending(p1, p2 pendingStatus) pendingStatus { + p := Pending(p1.msg + ", " + p2.msg) + p.warnings = append(p1.warnings, p2.warnings...) + return *p +} diff --git a/controllers/operator/workflow/reconciling.go b/controllers/operator/workflow/reconciling.go new file mode 100644 index 000000000..6aaeac2aa --- /dev/null +++ b/controllers/operator/workflow/reconciling.go @@ -0,0 +1,69 @@ +package workflow + +import ( + "github.com/10gen/ops-manager-kubernetes/api/v1/status" + "go.uber.org/zap" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// reconcilingStatus indicates that the reconciliation process has started +type reconcilingStatus struct { + commonStatus + eraseMessage bool +} + +func Reconciling() *reconcilingStatus { + return &reconcilingStatus{} +} + +// WithResourcesNotReady is intended to explicitly remove resourcesNotReady field from status as soon +// as the resources are ready +func (p *reconcilingStatus) WithResourcesNotReady(resourcesNotReady []status.ResourceNotReady) *reconcilingStatus { + p.resourcesNotReady = resourcesNotReady + return p +} + +// WithNoMessage allows to explicitly erase the message in the status. This can be valuable in case the message is +// not relevant any more (e.g. StatefulSet was created) +func (p *reconcilingStatus) WithNoMessage() *reconcilingStatus { + p.eraseMessage = true + return p +} + +func (o reconcilingStatus) ReconcileResult() (reconcile.Result, error) { + // not expected to be called + return reconcile.Result{}, nil +} + +func (o reconcilingStatus) IsOK() bool { + return true +} + +func (o reconcilingStatus) Merge(other Status) Status { + // any other status takes precedence over Reconciling + return other +} + +func (o reconcilingStatus) OnErrorPrepend(_ string) Status { + return o +} + +func (o reconcilingStatus) StatusOptions() []status.Option { + options := []status.Option{} + // We will override fields only if they were specified explicitly + if o.resourcesNotReady != nil { + options = append(options, status.NewResourcesNotReadyOption(o.resourcesNotReady)) + } + if o.eraseMessage { + options = append(options, status.NewMessageOption("")) + } + return options +} + +func (f reconcilingStatus) Log(_ *zap.SugaredLogger) { + // Doing no logging - the reconciler will do instead +} + +func (o reconcilingStatus) Phase() status.Phase { + return status.PhaseReconciling +} diff --git a/controllers/operator/workflow/status.go b/controllers/operator/workflow/status.go new file mode 100644 index 000000000..d40e9b4ff --- /dev/null +++ b/controllers/operator/workflow/status.go @@ -0,0 +1,65 @@ +package workflow + +import ( + "fmt" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/util/apierrors" + + "github.com/10gen/ops-manager-kubernetes/api/v1/status" + "go.uber.org/zap" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// reconcileStatus serves as a container holding the status of the custom resource +// The main reason why it's needed is to allow to pass the information about the state of the resource back to the +// calling functions up to the top-level 'reconcile' avoiding multiple return parameters and 'if' statements +type Status interface { + // Merge performs the Merge of current status with the status returned from the other operation and returns the + // new status + Merge(other Status) Status + + // IsOK returns true if there was no signal to interrupt reconciliation process + IsOK() bool + + // OnErrorPrepend prepends the msg in the case of an error reconcileStatus + OnErrorPrepend(msg string) Status + + // Returns options that can be used to populate the CR status + StatusOptions() []status.Option + + // Phase is the phase the status should get + Phase() status.Phase + + // ReconcileResult returns the result of reconciliation to be returned by main controller + ReconcileResult() (reconcile.Result, error) + + // Log performs logging of the status at some level if necessary + Log(log *zap.SugaredLogger) +} + +type commonStatus struct { + msg string + warnings []status.Warning + resourcesNotReady []status.ResourceNotReady +} + +func newCommonStatus(msg string, params ...interface{}) commonStatus { + return commonStatus{msg: fmt.Sprintf(msg, params...)} +} + +func (c *commonStatus) prependMsg(msg string) { + c.msg = msg + " " + c.msg +} + +func (c commonStatus) statusOptions() []status.Option { + // don't display any message on the MongoDB resource if the error is transient. + msg := c.msg + if apierrors.IsTransientMessage(msg) { + msg = "" + } + return []status.Option{ + status.NewMessageOption(msg), + status.NewWarningsOption(c.warnings), + status.NewResourcesNotReadyOption(c.resourcesNotReady), + } +} diff --git a/controllers/operator/workflow/status_test.go b/controllers/operator/workflow/status_test.go new file mode 100644 index 000000000..0aaad7aba --- /dev/null +++ b/controllers/operator/workflow/status_test.go @@ -0,0 +1,22 @@ +package workflow + +import ( + "golang.org/x/xerrors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestOnErrorPrepend(t *testing.T) { + result := Pending("my message") + decoratedResult := result.OnErrorPrepend("some prefix").(pendingStatus) + assert.Equal(t, "some prefix my message", decoratedResult.msg) + + failedResult := Failed(xerrors.Errorf("my failed result")) + failedDecoratedResult := failedResult.OnErrorPrepend("failed wrapper").(failedStatus) + assert.Equal(t, "failed wrapper my failed result", failedDecoratedResult.msg) + + failedValidationResult := Invalid("my failed validation") + failedDecoratedValidationResult := failedValidationResult.OnErrorPrepend("failed wrapper").(invalidStatus) + assert.Equal(t, "failed wrapper my failed validation", failedDecoratedValidationResult.msg) +} diff --git a/controllers/operator/workflow/unsupported.go b/controllers/operator/workflow/unsupported.go new file mode 100644 index 000000000..b249cec00 --- /dev/null +++ b/controllers/operator/workflow/unsupported.go @@ -0,0 +1,18 @@ +package workflow + +import ( + "github.com/10gen/ops-manager-kubernetes/api/v1/status" +) + +// unsupportedStatus indicates that the subresource is not supported by the current Operator +type unsupportedStatus struct { + *okStatus +} + +func Unsupported(msg string, params ...interface{}) *unsupportedStatus { + return &unsupportedStatus{okStatus: &okStatus{requeue: false, commonStatus: newCommonStatus(msg, params...)}} +} + +func (d unsupportedStatus) Phase() status.Phase { + return status.PhaseUnsupported +} diff --git a/controllers/operator/workflow/workflow.go b/controllers/operator/workflow/workflow.go new file mode 100644 index 000000000..e02adc597 --- /dev/null +++ b/controllers/operator/workflow/workflow.go @@ -0,0 +1,21 @@ +package workflow + +// RunInGivenOrder will execute N functions, passed as varargs as `funcs`. The order of execution will depend on the result +// of the evaluation of the `shouldRunInOrder` boolean value. If `shouldRunInOrder` is true, the functions will be executed in order; if +// `shouldRunInOrder` is false, the functions will be executed in reverse order (from last to first) +func RunInGivenOrder(shouldRunInOrder bool, funcs ...func() Status) Status { + if shouldRunInOrder { + for _, fn := range funcs { + if status := fn(); !status.IsOK() { + return status + } + } + } else { + for i := len(funcs) - 1; i >= 0; i-- { + if status := funcs[i](); !status.IsOK() { + return status + } + } + } + return OK() +} diff --git a/deploy/crd-and-csv-generation.md b/deploy/crd-and-csv-generation.md new file mode 100644 index 000000000..56e24bd9e --- /dev/null +++ b/deploy/crd-and-csv-generation.md @@ -0,0 +1,58 @@ +# Generating CRDs with the operator-sdk tool + +# prequisites +* The latest version of the [operator-sdk](https://github.com/operator-framework/operator-sdk/releases) + +We can generate crds with the following command + +```bash +operator-sdk generate crds +``` + +This defaults to creating the crds in `deploy/crds/` + + +# Generating the CSV + +Generating the CSV file can be done with the following command + +```bash +operator-sdk generate csv --operator-name mongodb-enterprise --csv-version 1.5.4 --apis-dir api --from-version 1.5.3 +``` + +The default directory the CR examples comes from is `deploy/crds`, but it can be specified +with `--crd-dir `. Note: the CRDs need to exist in this directory for the examples to be shown. + +In order for the deployments, roles and service accounts to be shown, they need to appear +as as yaml files in the `deploy` directory. + + +# Annotation Tips + +* For embedded structs, using `json:",inline"`. This will inherit all of the json tags on the embedded struct. +* Exclude fields from the CRD with `json:"-"` +* Any comments above a field or type will appear as the description of that field or object in the CRD. +* Add printer columns +```golang +// +kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase",description="The current state of the MongoDB User." +``` +* Add a subresource +```golang +// +kubebuilder:subresource:status +``` +* Make a field `// +optional` or `// +required` + +# Leaving dev comments + +Dev comments can be left by having a space between the description and the comment. +```golang + +// Description of MongodbSpec +type MongoDbSpec struct { + + // DEV COMMENT, won't show up in CRD + + // This is the description of Service + Service string `json:"service,omitempty"` + +``` diff --git a/deploy/crds/samples/ops-manager.yaml b/deploy/crds/samples/ops-manager.yaml new file mode 100644 index 000000000..4eb04d120 --- /dev/null +++ b/deploy/crds/samples/ops-manager.yaml @@ -0,0 +1,36 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: ops-manager +spec: + # the number of Ops Manager instances to run. Set to value bigger + # than 1 to get high availability and upgrades without downtime + replicas: 3 + + # the version of Ops Manager distro to use + version: 5.0.2 + + # optional. Specify the custom cluster domain of the Kubernetes cluster if it's different from the default one ('cluster.local'). + # This affects the urls generated by the Operator. + # This field is also used for Application Database url + clusterDomain: mycompany.net + + # the name of the secret containing admin user credentials. + # Either remove the secret or change the password using Ops Manager UI after the Ops Manager + # resource is created! + adminCredentials: ops-manager-admin-secret + + # optional. The Ops Manager configuration. All the values must be of type string + configuration: + mms.fromEmailAddr: "admin@example.com" + + # the application database backing Ops Manager. Replica Set is the only supported type + # Application database has the SCRAM-SHA authentication mode always enabled + applicationDatabase: + members: 3 + # optional. Configures the version of MongoDB used as an application database. + # The bundled MongoDB binary will be used if omitted and no download from the Internet will happen + version: 4.2.6-ent + podSpec: + cpu: '0.25' diff --git a/deploy/crds/samples/replica-set-scram-user.yaml b/deploy/crds/samples/replica-set-scram-user.yaml new file mode 100644 index 000000000..0e43b9f27 --- /dev/null +++ b/deploy/crds/samples/replica-set-scram-user.yaml @@ -0,0 +1,22 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDBUser +metadata: + name: my-scram-user +spec: + passwordSecretKeyRef: + name: my-scram-secret # the name of the secret that stores this user's password + key: password # the key in the secret that stores the password + username: my-scram-user + db: admin + mongodbResourceRef: + name: my-scram-enabled-replica-set # The name of the MongoDB resource this user will be added to + roles: + - db: admin + name: clusterAdmin + - db: admin + name: userAdminAnyDatabase + - db: admin + name: readWrite + - db: admin + name: userAdminAnyDatabase diff --git a/deploy/crds/samples/replica-set.yaml b/deploy/crds/samples/replica-set.yaml new file mode 100644 index 000000000..bac8695b1 --- /dev/null +++ b/deploy/crds/samples/replica-set.yaml @@ -0,0 +1,20 @@ +# +# This is a minimal config. To see all the options available, refer to the +# "extended" directory +# +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: my-replica-set +spec: + members: 3 + version: 4.2.1-ent + type: ReplicaSet + + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + persistent: false diff --git a/deploy/helm-charts b/deploy/helm-charts new file mode 160000 index 000000000..975841468 --- /dev/null +++ b/deploy/helm-charts @@ -0,0 +1 @@ +Subproject commit 975841468b21a99b388c279e37ecd5538bdab691 diff --git a/deploy/redhat_connect_zip_files/mongodb-enterprise.v1.7.0.zip b/deploy/redhat_connect_zip_files/mongodb-enterprise.v1.7.0.zip new file mode 100644 index 0000000000000000000000000000000000000000..5ea30aa85e2150e0cc88eef023dd4f5322f47227 GIT binary patch literal 290885 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

cCG!x_y#ljqKJ$Y>YDNI)+yqoq_t#7v*M` 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

b+tH+Ioqo!!bR2+r`xC5y__`af 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

tYbGS8#O$CnATn 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 literal 0 HcmV?d00001 diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 000000000..e69de29bb diff --git a/docker/cluster-cleaner/Chart.yaml b/docker/cluster-cleaner/Chart.yaml new file mode 100644 index 000000000..df1526278 --- /dev/null +++ b/docker/cluster-cleaner/Chart.yaml @@ -0,0 +1,3 @@ +name: cluster-cleaner +description: The background cleaner +version: 0.12 diff --git a/docker/cluster-cleaner/Dockerfile b/docker/cluster-cleaner/Dockerfile new file mode 100644 index 000000000..0fcb72fe2 --- /dev/null +++ b/docker/cluster-cleaner/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3-slim-buster + +ADD https://storage.googleapis.com/kubernetes-release/release/v1.13.3/bin/linux/amd64/kubectl /usr/bin +RUN chmod +x /usr/bin/kubectl + +COPY scripts/* / diff --git a/docker/cluster-cleaner/Makefile b/docker/cluster-cleaner/Makefile new file mode 100644 index 000000000..be433aca0 --- /dev/null +++ b/docker/cluster-cleaner/Makefile @@ -0,0 +1,24 @@ +IMAGE_VERSION=0.12 + +.PHONY: all +all: build push install + +build: + docker build --platform linux/amd64 -t dev/cluster-cleaner:$(IMAGE_VERSION) . + +push: + $(aws ecr get-login --no-include-email --region us-east-1 || true) + docker tag dev/cluster-cleaner:$(IMAGE_VERSION) 268558157000.dkr.ecr.us-east-1.amazonaws.com/dev/cluster-cleaner:$(IMAGE_VERSION) + docker tag dev/cluster-cleaner:$(IMAGE_VERSION) 268558157000.dkr.ecr.us-east-1.amazonaws.com/dev/cluster-cleaner:latest + + docker push 268558157000.dkr.ecr.us-east-1.amazonaws.com/dev/cluster-cleaner:$(IMAGE_VERSION) + docker push 268558157000.dkr.ecr.us-east-1.amazonaws.com/dev/cluster-cleaner:latest + +install: build push + kubectl create namespace cluster-cleaner || true + helm template . \ + --set cleanerVersion=$(IMAGE_VERSION) \ + --set namespace=cluster-cleaner\ + --set cleanerNamespace=cluster-cleaner > cluster-cleaner.yaml + kubectl apply -f cluster-cleaner.yaml + rm cluster-cleaner.yaml diff --git a/docker/cluster-cleaner/readme.md b/docker/cluster-cleaner/readme.md new file mode 100644 index 000000000..0c4583308 --- /dev/null +++ b/docker/cluster-cleaner/readme.md @@ -0,0 +1,35 @@ +# Cluster-Cleaner + +The `cluster-cleaner` is a series of scripts in a Docker image that it is +supposed to run on the Kubernetes cluster via CronJobs. + +## Installing a new version + +When making changes to any of the scripts, or adding CronJobs, the image needs +to be rebuild, just increase the version number both in `Chart.yaml` and +`Makefile`. The process of publishing a new version is: + +* Make changes to bash scripts and CronJob yaml files +* Run `make build && make push` + +The CronJobs will be installed, and they will always point at the `latest` +version. + +## Running the scripts locally + +These cleaning scripts can be run locally, just make sure you are pointing at +the right Kubernetes cluster and set the required environment variables. + +### Examples + +* To restart Ops Manager: + + OM_NAMESPACE=operator-testing-42-current ./clean-ops-manager.sh + +* To clean failed namespaces: + + DELETE_OLDER_THAN_AMOUNT=10 DELETE_OLDER_THAN_UNIT=minutes ./clean-failed-namespaces.sh + +* To clean old builder Pods: + + DELETE_OLDER_THAN_AMOUNT=1 DELETE_OLDER_THAN_UNIT=days ./delete-old-builder-pods.sh diff --git a/docker/cluster-cleaner/scripts/clean-cluster-roles-and-bindings.sh b/docker/cluster-cleaner/scripts/clean-cluster-roles-and-bindings.sh new file mode 100755 index 000000000..42106df75 --- /dev/null +++ b/docker/cluster-cleaner/scripts/clean-cluster-roles-and-bindings.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env sh + +echo "Deleting ClusterRoles" + +for role in $(kubectl get clusterrole -o name | grep -E "mongodb-enterprise-operator|mdb-operator|operator-multi-cluster-tests-role-binding|operator-tests-role-binding") ; do + creation_time=$(kubectl get "${role}" -o jsonpath='{.metadata.creationTimestamp}') + + if ! ./is_older_than.py "${creation_time}" 1 hours; then + continue + fi + + kubectl delete "${role}" +done + +echo "Deleting ClusterRoleBinding" +for binding in $(kubectl get clusterrolebinding -o name | grep -E "mongodb-enterprise-operator|mdb-operator|operator-multi-cluster-tests-role-binding|operator-tests-role-binding"); do + creation_time=$(kubectl get "${binding}" -o jsonpath='{.metadata.creationTimestamp}') + + if ! ./is_older_than.py "${creation_time}" 1 hours; then + continue + fi + + kubectl delete "${binding}" +done diff --git a/docker/cluster-cleaner/scripts/clean-failed-namespaces.sh b/docker/cluster-cleaner/scripts/clean-failed-namespaces.sh new file mode 100755 index 000000000..1c30ad458 --- /dev/null +++ b/docker/cluster-cleaner/scripts/clean-failed-namespaces.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env sh + +if [ -z ${DELETE_OLDER_THAN_AMOUNT+x} ] || [ -z ${DELETE_OLDER_THAN_UNIT+x} ]; then + echo "Need to set both 'DELETE_OLDER_THAN_AMOUNT' and 'DELETE_OLDER_THAN_UNIT' environment variables." + exit 1 +fi + +if [ -z ${LABELS+x} ]; then + echo "Need to set 'LABELS' environment variables." + exit 1 +fi + +echo "Deleting namespaces for evg tasks that are older than ${DELETE_OLDER_THAN_AMOUNT} ${DELETE_OLDER_THAN_UNIT} with label ${LABELS}" +for namespace in $(kubectl get namespace -l "${LABELS}" -o name); do + creation_time=$(kubectl get "${namespace}" -o jsonpath='{.metadata.creationTimestamp}') + + if ! ./is_older_than.py "${creation_time}" "${DELETE_OLDER_THAN_AMOUNT}" "${DELETE_OLDER_THAN_UNIT}"; then + continue + fi + + namespace_name=$(echo "${namespace}" | cut -d '/' -f 2) + + csrs_in_namespace=$(kubectl get csr -o name | grep "${namespace_name}") + kubectl delete "${csrs_in_namespace}" + + kubectl delete mdb --all -n "${namespace_name=}" + kubectl delete mdbu --all -n "${namespace_name=}" + kubectl delete "${namespace}" +done diff --git a/docker/cluster-cleaner/scripts/clean-ops-manager.sh b/docker/cluster-cleaner/scripts/clean-ops-manager.sh new file mode 100755 index 000000000..674700c4c --- /dev/null +++ b/docker/cluster-cleaner/scripts/clean-ops-manager.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env sh + +if [ -z "${OM_NAMESPACE}" ]; then + echo "OM_NAMESPACE env variable is not specified"; + exit 1 +fi + +echo "Removing Ops Manager in ${OM_NAMESPACE}" + +kubectl --namespace "${OM_NAMESPACE}" delete om ops-manager + diff --git a/docker/cluster-cleaner/scripts/construction-site.sh b/docker/cluster-cleaner/scripts/construction-site.sh new file mode 100755 index 000000000..96aacd21a --- /dev/null +++ b/docker/cluster-cleaner/scripts/construction-site.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +# +# Builds the `construction-site` namespace and docker config configmap +# + +kubectl create namespace construction-site || true + +kubectl -n construction-site create configmap docker-config --from-literal=config.json='{"credHelpers":{"268558157000.dkr.ecr.us-east-1.amazonaws.com":"ecr-login"}}' diff --git a/docker/cluster-cleaner/scripts/create-cluster-ca-as-configmap.sh b/docker/cluster-cleaner/scripts/create-cluster-ca-as-configmap.sh new file mode 100755 index 000000000..5067c26f9 --- /dev/null +++ b/docker/cluster-cleaner/scripts/create-cluster-ca-as-configmap.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env sh + +kube_ca_file="/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" + +if ! kubectl -n default get configmap/ca-certificates; then + echo "Creating the ca-certificates for this Kubernetes cluster" + kubectl -n default create configmap ca-certificates --from-file=ca-pem=${kube_ca_file} +else + echo "ca-certificates configmap already exists." +fi diff --git a/docker/cluster-cleaner/scripts/delete-old-builder-pods.sh b/docker/cluster-cleaner/scripts/delete-old-builder-pods.sh new file mode 100755 index 000000000..5caa2e8c4 --- /dev/null +++ b/docker/cluster-cleaner/scripts/delete-old-builder-pods.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +NAMESPACE=construction-site + +if [ -z ${DELETE_OLDER_THAN_AMOUNT+x} ] || [ -z ${DELETE_OLDER_THAN_UNIT+x} ]; then + echo "Need to set both 'DELETE_OLDER_THAN_AMOUNT' and 'DELETE_OLDER_THAN_UNIT' environment variables." + exit 1 +fi + +for pod in $(kubectl -n $NAMESPACE get pods -o name); do + creation_time=$(kubectl -n $NAMESPACE get "${pod}" -o jsonpath='{.metadata.creationTimestamp}') + status=$(kubectl get $pod -o jsonpath='{.status.phase}' -n "${NAMESPACE}") + + if [[ "$status" != "Succeeded" ]] && [[ "$status" != "Failed" ]]; then + # we don't remove pending tasks + continue + fi + + if ! ./is_older_than.py "${creation_time}" "${DELETE_OLDER_THAN_AMOUNT}" "${DELETE_OLDER_THAN_UNIT}"; then + continue + fi + kubectl -n $NAMESPACE delete "${pod}" +done diff --git a/docker/cluster-cleaner/scripts/is_older_than.py b/docker/cluster-cleaner/scripts/is_older_than.py new file mode 100755 index 000000000..812a9e1c7 --- /dev/null +++ b/docker/cluster-cleaner/scripts/is_older_than.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 + +# is_older_than.py +# +# Usage: +# +# ./is_older_than.py +# +# Exits with a successful exit code if is older than s of time. +# The format of is a simple iso datetime as returned by +# +# kubectl get namespaces -o jsonpath='{.items[*].metadata.creationTimestamp}' +# +# Example: +# +# 1. Check if a given timestamp is older than 6 hours +# ./is_older_than.py 2019-02-22T11:28:04Z 6 hours +# +# 2. Check if a given timestamp is older than 3 days +# ./is_older_than.py 2019-02-22T11:28:04Z 3 days +# +# 3. Check if Rodrigo is older than 39 years. +# This command will return 1 until my next birthday. +# ./is_older_than.py 1980-25-04T11:00:04Z 39 years +# + +from datetime import datetime, timedelta +import sys + + +def is_older_than(date, amount, unit): + """Checks if datetime is older than `amount` of `unit`""" + date = datetime.strptime(date, "%Y-%m-%dT%H:%M:%SZ") + # gets the following options, same as we use to construct the timedelta object, + # like 'minutes 6' -- it is expected in command line as '6 minutes' + delta_options = {unit: amount} + delta = timedelta(**delta_options) + + return date + delta > datetime.now() + + +if __name__ == "__main__": + date = sys.argv[1] + amount = int(sys.argv[2]) + unit = sys.argv[3] + + sys.exit(is_older_than(date, amount, unit)) diff --git a/docker/cluster-cleaner/templates/job.yaml b/docker/cluster-cleaner/templates/job.yaml new file mode 100644 index 000000000..8ebd1242c --- /dev/null +++ b/docker/cluster-cleaner/templates/job.yaml @@ -0,0 +1,159 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: cluster-cleaner + namespace: {{ .Values.cleanerNamespace }} + +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: cluster-cleaner + namespace: {{ .Values.cleanerNamespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: +- kind: ServiceAccount + name: cluster-cleaner + namespace: {{ .Values.cleanerNamespace }} + +# Remove old failed namespaces, which are older than 20 minutes. +# This CJ runs every 10 minutes, so a failed namespace will live for +# at least 20 minutes, but can live up to 30. +--- +apiVersion: batch/v1beta1 +kind: CronJob +metadata: + name: cluster-cleaner-delete-each-hour + namespace: {{ .Values.cleanerNamespace }} +spec: + # Run every 10 minutes + schedule: "*/10 * * * *" + jobTemplate: + spec: + template: + spec: + serviceAccountName: cluster-cleaner + restartPolicy: Never + + containers: + - name: cluster-cleaner + image: 268558157000.dkr.ecr.us-east-1.amazonaws.com/dev/cluster-cleaner:{{ .Values.cleanerVersion }} + imagePullPolicy: Always + command: ["./clean-failed-namespaces.sh"] + env: + - name: DELETE_OLDER_THAN_UNIT + value: "minutes" + - name: DELETE_OLDER_THAN_AMOUNT + value: "20" + - name: LABELS + value: "evg=task,evg/state=failed" + +# Remove old testing namespaces, no matter if they have succeeded or failed. +# This is run every 40 minutes, so every testing namespace, even if it has not +# finished running it will be removed. +--- +apiVersion: batch/v1beta1 +kind: CronJob +metadata: + name: cluster-cleaner-delete-each-hour-all + namespace: {{ .Values.cleanerNamespace }} +spec: + # Run every 10 minutes + schedule: "*/10 * * * *" + jobTemplate: + spec: + template: + spec: + serviceAccountName: cluster-cleaner + restartPolicy: Never + + containers: + - name: cluster-cleaner + image: 268558157000.dkr.ecr.us-east-1.amazonaws.com/dev/cluster-cleaner:{{ .Values.cleanerVersion }} + imagePullPolicy: Always + command: ["./clean-failed-namespaces.sh"] + env: + - name: DELETE_OLDER_THAN_UNIT + value: "minutes" + - name: DELETE_OLDER_THAN_AMOUNT + value: "40" + - name: LABELS + value: "evg=task" + +# Clean old builder pods +--- +apiVersion: batch/v1beta1 +kind: CronJob +metadata: + name: cluster-cleaner-delete-builder-pods + namespace: {{ .Values.cleanerNamespace }} +spec: + # Runs every hour + schedule: "0 * * * *" + jobTemplate: + spec: + template: + spec: + serviceAccountName: cluster-cleaner + restartPolicy: Never + + containers: + - name: cluster-cleaner + image: 268558157000.dkr.ecr.us-east-1.amazonaws.com/dev/cluster-cleaner:{{ .Values.cleanerVersion }} + imagePullPolicy: Always + command: ["./delete-old-builder-pods.sh"] + env: + - name: DELETE_OLDER_THAN_UNIT + value: "minutes" + - name: DELETE_OLDER_THAN_AMOUNT + value: "20" + +# Clean old certificates +--- +apiVersion: batch/v1beta1 +kind: CronJob +metadata: + name: cluster-cleaner-ca-certificates + namespace: {{ .Values.cleanerNamespace }} +spec: + # Run at 6:17 am every day of the week + schedule: "17 6 * * *" + jobTemplate: + spec: + template: + spec: + serviceAccountName: cluster-cleaner + restartPolicy: Never + + containers: + - name: cluster-cleaner + image: 268558157000.dkr.ecr.us-east-1.amazonaws.com/dev/cluster-cleaner:{{ .Values.cleanerVersion }} + imagePullPolicy: Always + command: ["./create-cluster-ca-as-configmap.sh"] + +# Remove old clusterroles +--- +apiVersion: batch/v1beta1 +kind: CronJob +metadata: + name: cluster-cleaner-cluster-roles-and-bindings + namespace: {{ .Values.cleanerNamespace }} +spec: + # Run at 4:25 every day of the week + schedule: "25 4 * * *" + jobTemplate: + spec: + template: + spec: + serviceAccountName: cluster-cleaner + restartPolicy: Never + + containers: + - name: cluster-cleaner + image: 268558157000.dkr.ecr.us-east-1.amazonaws.com/dev/cluster-cleaner:{{ .Values.cleanerVersion }} + imagePullPolicy: Always + command: ["./clean-cluster-roles-and-bindings.sh"] diff --git a/docker/cluster-cleaner/templates/ops_manager_cleaner_job.yaml b/docker/cluster-cleaner/templates/ops_manager_cleaner_job.yaml new file mode 100644 index 000000000..5308cbb85 --- /dev/null +++ b/docker/cluster-cleaner/templates/ops_manager_cleaner_job.yaml @@ -0,0 +1,48 @@ +{{ if .Values.namespace }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: ops-manager-cleaner + namespace: {{ .Values.namespace }} + +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: ops-manager-cleaner + namespace: {{ .Values.namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: +- kind: ServiceAccount + name: ops-manager-cleaner + namespace: {{ .Values.namespace }} + +--- +apiVersion: batch/v1beta1 +kind: CronJob +metadata: + name: cluster-cleaner-ops-manager + namespace: {{ .Values.namespace }} +spec: + # Run at 3:00 am every day. + schedule: "0 3 * * *" + jobTemplate: + spec: + template: + spec: + serviceAccountName: ops-manager-cleaner + restartPolicy: Never + + containers: + - name: cluster-cleaner + image: 268558157000.dkr.ecr.us-east-1.amazonaws.com/dev/cluster-cleaner:{{ .Values.cleanerVersion }} + imagePullPolicy: Always + command: ["./clean-ops-manager.sh"] + env: + - name: OM_NAMESPACE + value: {{ .Values.namespace }} +{{ end }} diff --git a/docker/mongodb-enterprise-appdb-database/4.0/ubi/Dockerfile b/docker/mongodb-enterprise-appdb-database/4.0/ubi/Dockerfile new file mode 100644 index 000000000..1b5d11310 --- /dev/null +++ b/docker/mongodb-enterprise-appdb-database/4.0/ubi/Dockerfile @@ -0,0 +1,85 @@ +FROM registry.access.redhat.com/ubi8/ubi + +ARG MONGO_VERSION + +LABEL name="MongoDB Enterprise AppDB Database" \ + version=${MONGO_VERSION} \ + summary="MongoDB Enterprise AppDB Database" \ + description="MongoDB Enterprise AppDB Database" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +ADD licenses /licenses + +# add our user and group first to make sure their IDs get assigned consistently, regardless of whatever dependencies get added +RUN groupadd -r mongodb && useradd -r -g mongodb mongodb + + +RUN set -eux; \ + yum install -y --disableplugin=subscription-manager \ + ca-certificates \ + jq \ + ; \ + if ! command -v ps > /dev/null; then \ + yum install -y --disableplugin=subscription-managers procps; \ + fi; + +# grab gosu for easy step-down from root (https://github.com/tianon/gosu/releases) +ENV GOSU_VERSION 1.14 +# grab "js-yaml" for parsing mongod's YAML config files (https://github.com/nodeca/js-yaml/releases) +ENV JSYAML_VERSION 3.13.1 + +RUN set -ex; \ + \ + yum install -y --disableplugin=subscription-manager \ + wget \ + ; \ + if ! command -v gpg > /dev/null; then \ + yum install -y --disableplugin=subscription-manager gnupg dirmngr; \ + elif gpg --version | grep -q '^gpg (GnuPG) 1\.'; then \ +# "This package provides support for HKPS keyservers." (GnuPG 1.x only) + yum install -y --disableplugin=subscription-manager gnupg-curl; \ + fi; \ + \ + dpkgArch="$(arch| sed 's/x86_64/amd64/')"; \ + wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \ + wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \ + export GNUPGHOME="$(mktemp -d)"; \ + gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \ + gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \ + command -v gpgconf && gpgconf --kill all || :; \ + rm -r "$GNUPGHOME" /usr/local/bin/gosu.asc; \ + \ + wget -O /js-yaml.js "https://github.com/nodeca/js-yaml/raw/${JSYAML_VERSION}/dist/js-yaml.js"; \ + chmod +x /usr/local/bin/gosu; \ + gosu --version; \ + gosu nobody true + +RUN mkdir /docker-entrypoint-initdb.d + +# # Allow build-time overrides (eg. to build image with MongoDB Enterprise version) +# # Options for MONGO_PACKAGE: mongodb-org OR mongodb-enterprise +# # Options for MONGO_REPO: repo.mongodb.org OR repo.mongodb.com +# # Example: docker build --build-arg MONGO_PACKAGE=mongodb-enterprise --build-arg MONGO_REPO=repo.mongodb.com . +ARG MONGO_PACKAGE=mongodb-org +ARG MONGO_REPO=repo.mongodb.org +ENV MONGO_PACKAGE=${MONGO_PACKAGE} MONGO_REPO=${MONGO_REPO} + +ENV MONGO_MAJOR 4.0 + +COPY 4.0/ubi/mongodb-org-4.0.repo /etc/yum.repos.d/mongodb-org-4.0.repo + + +RUN yum install -y mongodb-enterprise-${MONGO_VERSION} mongodb-enterprise-server-${MONGO_VERSION} mongodb-enterprise-shell-${MONGO_VERSION} mongodb-enterprise-mongos-${MONGO_VERSION} mongodb-enterprise-tools-${MONGO_VERSION} + +RUN echo "exclude=mongodb-org,mongodb-org-server,mongodb-org-shell,mongodb-org-mongos,mongodb-org-tools" >> /etc/yum.conf +RUN mkdir -p /data/db /data/configdb \ + && chown -R mongodb:mongodb /data/db /data/configdb +VOLUME /data/db /data/configdb + +COPY docker-entrypoint.sh /usr/local/bin/ +ENTRYPOINT ["docker-entrypoint.sh"] + +EXPOSE 27017 +CMD ["mongod"] diff --git a/docker/mongodb-enterprise-appdb-database/4.0/ubi/mongodb-org-4.0.repo b/docker/mongodb-enterprise-appdb-database/4.0/ubi/mongodb-org-4.0.repo new file mode 100644 index 000000000..18a2fc0e9 --- /dev/null +++ b/docker/mongodb-enterprise-appdb-database/4.0/ubi/mongodb-org-4.0.repo @@ -0,0 +1,6 @@ +[mongodb-enterprise-4.0] +name=MongoDB Enterprise Repository +baseurl=https://repo.mongodb.com/yum/redhat/$releasever/mongodb-enterprise/4.0/$basearch/ +gpgcheck=1 +enabled=1 +gpgkey=https://www.mongodb.org/static/pgp/server-4.0.asc diff --git a/docker/mongodb-enterprise-appdb-database/4.0/ubuntu/Dockerfile b/docker/mongodb-enterprise-appdb-database/4.0/ubuntu/Dockerfile new file mode 100644 index 000000000..81e5c7c30 --- /dev/null +++ b/docker/mongodb-enterprise-appdb-database/4.0/ubuntu/Dockerfile @@ -0,0 +1,125 @@ +FROM ubuntu:xenial-20210416 + +ARG MONGO_VERSION + +LABEL name="MongoDB Enterprise AppDB Database" \ + version=${MONGO_VERSION} \ + summary="MongoDB Enterprise AppDB Database" \ + description="MongoDB Enterprise AppDB Database" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +ADD licenses /licenses + +# add our user and group first to make sure their IDs get assigned consistently, regardless of whatever dependencies get added +RUN groupadd -r mongodb && useradd -r -g mongodb mongodb + +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + jq \ + numactl \ + ; \ + if ! command -v ps > /dev/null; then \ + apt-get install -y --no-install-recommends procps; \ + fi; \ + rm -rf /var/lib/apt/lists/* + +# grab gosu for easy step-down from root (https://github.com/tianon/gosu/releases) +ENV GOSU_VERSION 1.14 +# grab "js-yaml" for parsing mongod's YAML config files (https://github.com/nodeca/js-yaml/releases) +ENV JSYAML_VERSION 3.13.1 + +RUN mkdir ~/.gnupg +RUN echo "disable-ipv6" >> ~/.gnupg/dirmngr.conf +RUN set -ex; \ + \ + savedAptMark="$(apt-mark showmanual)"; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + wget \ + ; \ + if ! command -v gpg > /dev/null; then \ + apt-get install -y --no-install-recommends gnupg dirmngr; \ + savedAptMark="$savedAptMark gnupg dirmngr"; \ + elif gpg --version | grep -q '^gpg (GnuPG) 1\.'; then \ +# "This package provides support for HKPS keyservers." (GnuPG 1.x only) + apt-get install -y --no-install-recommends gnupg-curl; \ + fi; \ + rm -rf /var/lib/apt/lists/*; \ + \ + dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \ + wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \ + wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \ + export GNUPGHOME="$(mktemp -d)"; \ + gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \ + gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \ + command -v gpgconf && gpgconf --kill all || :; \ + rm -r "$GNUPGHOME" /usr/local/bin/gosu.asc; \ + \ + wget -O /js-yaml.js "https://github.com/nodeca/js-yaml/raw/${JSYAML_VERSION}/dist/js-yaml.js"; \ +# TODO some sort of download verification here + \ + apt-mark auto '.*' > /dev/null; \ + apt-mark manual $savedAptMark > /dev/null; \ + apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false; \ + \ +# smoke test + chmod +x /usr/local/bin/gosu; \ + gosu --version; \ + gosu nobody true + +RUN mkdir /docker-entrypoint-initdb.d + +ENV GPG_KEYS 9DA31620334BD75D9DCB49F368818C72E52529D4 +RUN set -ex; \ + export GNUPGHOME="$(mktemp -d)"; \ + for key in $GPG_KEYS; do \ + gpg --batch --keyserver keyserver.ubuntu.com --recv-keys "$key"; \ + done; \ + gpg --batch --export $GPG_KEYS > /etc/apt/trusted.gpg.d/mongodb.gpg; \ + command -v gpgconf && gpgconf --kill all || :; \ + rm -r "$GNUPGHOME"; \ + apt-key list + +# Allow build-time overrides (eg. to build image with MongoDB Enterprise version) +# Options for MONGO_PACKAGE: mongodb-org OR mongodb-enterprise +# Options for MONGO_REPO: repo.mongodb.org OR repo.mongodb.com +# Example: docker build --build-arg MONGO_PACKAGE=mongodb-enterprise --build-arg MONGO_REPO=repo.mongodb.com . +ARG MONGO_PACKAGE=mongodb-org +ARG MONGO_REPO=repo.mongodb.org +ENV MONGO_PACKAGE=${MONGO_PACKAGE} MONGO_REPO=${MONGO_REPO} + +ENV MONGO_MAJOR 4.0 + +# bashbrew-architectures:amd64 arm64v8 +RUN echo "deb http://$MONGO_REPO/apt/ubuntu xenial/${MONGO_PACKAGE%-unstable}/$MONGO_MAJOR multiverse" | tee "/etc/apt/sources.list.d/${MONGO_PACKAGE%-unstable}.list" + +RUN set -x \ +# installing "mongodb-enterprise" pulls in "tzdata" which prompts for input + && export DEBIAN_FRONTEND=noninteractive \ + && apt-get update \ +# starting with MongoDB 4.3 (and backported to 4.0 and 4.2 *and* 3.6??), the postinst for server includes an unconditional "systemctl daemon-reload" (and we don't have anything for "systemctl" to talk to leading to dbus errors and failed package installs) + && ln -s /bin/true /usr/local/bin/systemctl \ + && apt-get install -y \ + ${MONGO_PACKAGE}=$MONGO_VERSION \ + ${MONGO_PACKAGE}-server=$MONGO_VERSION \ + ${MONGO_PACKAGE}-shell=$MONGO_VERSION \ + ${MONGO_PACKAGE}-mongos=$MONGO_VERSION \ + ${MONGO_PACKAGE}-tools=$MONGO_VERSION \ + && rm -f /usr/local/bin/systemctl \ + && rm -rf /var/lib/apt/lists/* \ + && rm -rf /var/lib/mongodb \ + && mv /etc/mongod.conf /etc/mongod.conf.orig + +RUN mkdir -p /data/db /data/configdb \ + && chown -R mongodb:mongodb /data/db /data/configdb +VOLUME /data/db /data/configdb + +COPY docker-entrypoint.sh /usr/local/bin/ +ENTRYPOINT ["docker-entrypoint.sh"] + +EXPOSE 27017 +CMD ["mongod"] diff --git a/docker/mongodb-enterprise-appdb-database/4.2/ubi/Dockerfile b/docker/mongodb-enterprise-appdb-database/4.2/ubi/Dockerfile new file mode 100644 index 000000000..907d449ce --- /dev/null +++ b/docker/mongodb-enterprise-appdb-database/4.2/ubi/Dockerfile @@ -0,0 +1,87 @@ +FROM registry.access.redhat.com/ubi8/ubi + +ARG MONGO_VERSION + +LABEL name="MongoDB Enterprise AppDB Database" \ + version=${MONGO_VERSION} \ + summary="MongoDB Enterprise AppDB Database" \ + description="MongoDB Enterprise AppDB Database" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +ADD licenses /licenses + +# add our user and group first to make sure their IDs get assigned consistently, regardless of whatever dependencies get added +RUN groupadd -r mongodb && useradd -r -g mongodb mongodb + + +RUN set -eux; \ + yum install -y --disableplugin=subscription-manager \ + ca-certificates \ + jq \ + ; \ + if ! command -v ps > /dev/null; then \ + yum install -y --disableplugin=subscription-managers procps; \ + fi; + +# grab gosu for easy step-down from root (https://github.com/tianon/gosu/releases) +ENV GOSU_VERSION 1.14 +# grab "js-yaml" for parsing mongod's YAML config files (https://github.com/nodeca/js-yaml/releases) +ENV JSYAML_VERSION 3.13.1 + +RUN set -ex; \ + \ + yum install -y --disableplugin=subscription-manager \ + wget \ + ; \ + if ! command -v gpg > /dev/null; then \ + yum install -y --disableplugin=subscription-manager gnupg dirmngr; \ + elif gpg --version | grep -q '^gpg (GnuPG) 1\.'; then \ +# "This package provides support for HKPS keyservers." (GnuPG 1.x only) + yum install -y --disableplugin=subscription-manager gnupg-curl; \ + fi; \ + \ + dpkgArch="$(arch| sed 's/x86_64/amd64/')"; \ + wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \ + wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \ + export GNUPGHOME="$(mktemp -d)"; \ + gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \ + gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \ + command -v gpgconf && gpgconf --kill all || :; \ + rm -r "$GNUPGHOME" /usr/local/bin/gosu.asc; \ + \ + wget -O /js-yaml.js "https://github.com/nodeca/js-yaml/raw/${JSYAML_VERSION}/dist/js-yaml.js"; \ + chmod +x /usr/local/bin/gosu; \ + gosu --version; \ + gosu nobody true + + RUN mkdir /docker-entrypoint-initdb.d + +# # Allow build-time overrides (eg. to build image with MongoDB Enterprise version) +# # Options for MONGO_PACKAGE: mongodb-org OR mongodb-enterprise +# # Options for MONGO_REPO: repo.mongodb.org OR repo.mongodb.com +# # Example: docker build --build-arg MONGO_PACKAGE=mongodb-enterprise --build-arg MONGO_REPO=repo.mongodb.com . +ARG MONGO_PACKAGE=mongodb-org +ARG MONGO_REPO=repo.mongodb.org +ENV MONGO_PACKAGE=${MONGO_PACKAGE} MONGO_REPO=${MONGO_REPO} + +ENV MONGO_MAJOR 4.2 + +COPY 4.2/ubi/mongodb-org-4.2.repo /etc/yum.repos.d/mongodb-org-4.2.repo + + +RUN cat /etc/yum.repos.d/mongodb-org-4.2.repo + +RUN yum install -y mongodb-enterprise-${MONGO_VERSION} mongodb-enterprise-server-${MONGO_VERSION} mongodb-enterprise-shell-${MONGO_VERSION} mongodb-enterprise-mongos-${MONGO_VERSION} mongodb-enterprise-tools-${MONGO_VERSION} + +RUN echo "exclude=mongodb-org,mongodb-org-server,mongodb-org-shell,mongodb-org-mongos,mongodb-org-tools" >> /etc/yum.conf +RUN mkdir -p /data/db /data/configdb \ + && chown -R mongodb:mongodb /data/db /data/configdb +VOLUME /data/db /data/configdb + +COPY docker-entrypoint.sh /usr/local/bin/ +ENTRYPOINT ["docker-entrypoint.sh"] + +EXPOSE 27017 +CMD ["mongod"] diff --git a/docker/mongodb-enterprise-appdb-database/4.2/ubi/mongodb-org-4.2.repo b/docker/mongodb-enterprise-appdb-database/4.2/ubi/mongodb-org-4.2.repo new file mode 100644 index 000000000..836d01f3b --- /dev/null +++ b/docker/mongodb-enterprise-appdb-database/4.2/ubi/mongodb-org-4.2.repo @@ -0,0 +1,6 @@ +[mongodb-enterprise-4.2] +name=MongoDB Enterprise Repository +baseurl=https://repo.mongodb.com/yum/redhat/$releasever/mongodb-enterprise/4.2/$basearch/ +gpgcheck=1 +enabled=1 +gpgkey=https://www.mongodb.org/static/pgp/server-4.2.asc diff --git a/docker/mongodb-enterprise-appdb-database/4.2/ubuntu/Dockerfile b/docker/mongodb-enterprise-appdb-database/4.2/ubuntu/Dockerfile new file mode 100644 index 000000000..2be85b94f --- /dev/null +++ b/docker/mongodb-enterprise-appdb-database/4.2/ubuntu/Dockerfile @@ -0,0 +1,125 @@ +FROM ubuntu:xenial-20210416 + +ARG MONGO_VERSION + +LABEL name="MongoDB Enterprise AppDB Database" \ + version=${MONGO_VERSION} \ + summary="MongoDB Enterprise AppDB Database" \ + description="MongoDB Enterprise AppDB Database" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +ADD licenses /licenses + +# add our user and group first to make sure their IDs get assigned consistently, regardless of whatever dependencies get added +RUN groupadd -r mongodb && useradd -r -g mongodb mongodb + +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + jq \ + numactl \ + ; \ + if ! command -v ps > /dev/null; then \ + apt-get install -y --no-install-recommends procps; \ + fi; \ + rm -rf /var/lib/apt/lists/* + +# grab gosu for easy step-down from root (https://github.com/tianon/gosu/releases) +ENV GOSU_VERSION 1.14 +# grab "js-yaml" for parsing mongod's YAML config files (https://github.com/nodeca/js-yaml/releases) +ENV JSYAML_VERSION 3.13.1 + +RUN mkdir ~/.gnupg +RUN echo "disable-ipv6" >> ~/.gnupg/dirmngr.conf +RUN set -ex; \ + \ + savedAptMark="$(apt-mark showmanual)"; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + wget \ + ; \ + if ! command -v gpg > /dev/null; then \ + apt-get install -y --no-install-recommends gnupg dirmngr; \ + savedAptMark="$savedAptMark gnupg dirmngr"; \ + elif gpg --version | grep -q '^gpg (GnuPG) 1\.'; then \ +# "This package provides support for HKPS keyservers." (GnuPG 1.x only) + apt-get install -y --no-install-recommends gnupg-curl; \ + fi; \ + rm -rf /var/lib/apt/lists/*; \ + \ + dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \ + wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \ + wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \ + export GNUPGHOME="$(mktemp -d)"; \ + gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \ + gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \ + command -v gpgconf && gpgconf --kill all || :; \ + rm -r "$GNUPGHOME" /usr/local/bin/gosu.asc; \ + \ + wget -O /js-yaml.js "https://github.com/nodeca/js-yaml/raw/${JSYAML_VERSION}/dist/js-yaml.js"; \ +# TODO some sort of download verification here + \ + apt-mark auto '.*' > /dev/null; \ + apt-mark manual $savedAptMark > /dev/null; \ + apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false; \ + \ +# smoke test + chmod +x /usr/local/bin/gosu; \ + gosu --version; \ + gosu nobody true + +RUN mkdir /docker-entrypoint-initdb.d + +ENV GPG_KEYS E162F504A20CDF15827F718D4B7C549A058F8B6B +RUN set -ex; \ + export GNUPGHOME="$(mktemp -d)"; \ + for key in $GPG_KEYS; do \ + gpg --batch --keyserver keyserver.ubuntu.com --recv-keys "$key"; \ + done; \ + gpg --batch --export $GPG_KEYS > /etc/apt/trusted.gpg.d/mongodb.gpg; \ + command -v gpgconf && gpgconf --kill all || :; \ + rm -r "$GNUPGHOME"; \ + apt-key list + +# Allow build-time overrides (eg. to build image with MongoDB Enterprise version) +# Options for MONGO_PACKAGE: mongodb-org OR mongodb-enterprise +# Options for MONGO_REPO: repo.mongodb.org OR repo.mongodb.com +# Example: docker build --build-arg MONGO_PACKAGE=mongodb-enterprise --build-arg MONGO_REPO=repo.mongodb.com . +ARG MONGO_PACKAGE=mongodb-org +ARG MONGO_REPO=repo.mongodb.org +ENV MONGO_PACKAGE=${MONGO_PACKAGE} MONGO_REPO=${MONGO_REPO} + +ENV MONGO_MAJOR 4.2 + +# bashbrew-architectures:amd64 arm64v8 +RUN echo "deb http://$MONGO_REPO/apt/ubuntu xenial/${MONGO_PACKAGE%-unstable}/$MONGO_MAJOR multiverse" | tee "/etc/apt/sources.list.d/${MONGO_PACKAGE%-unstable}.list" + +RUN set -x \ +# installing "mongodb-enterprise" pulls in "tzdata" which prompts for input + && export DEBIAN_FRONTEND=noninteractive \ + && apt-get update \ +# starting with MongoDB 4.3 (and backported to 4.0 and 4.2 *and* 3.6??), the postinst for server includes an unconditional "systemctl daemon-reload" (and we don't have anything for "systemctl" to talk to leading to dbus errors and failed package installs) + && ln -s /bin/true /usr/local/bin/systemctl \ + && apt-get install -y \ + ${MONGO_PACKAGE}=$MONGO_VERSION \ + ${MONGO_PACKAGE}-server=$MONGO_VERSION \ + ${MONGO_PACKAGE}-shell=$MONGO_VERSION \ + ${MONGO_PACKAGE}-mongos=$MONGO_VERSION \ + ${MONGO_PACKAGE}-tools=$MONGO_VERSION \ + && rm -f /usr/local/bin/systemctl \ + && rm -rf /var/lib/apt/lists/* \ + && rm -rf /var/lib/mongodb \ + && mv /etc/mongod.conf /etc/mongod.conf.orig + +RUN mkdir -p /data/db /data/configdb \ + && chown -R mongodb:mongodb /data/db /data/configdb +VOLUME /data/db /data/configdb + +COPY docker-entrypoint.sh /usr/local/bin/ +ENTRYPOINT ["docker-entrypoint.sh"] + +EXPOSE 27017 +CMD ["mongod"] diff --git a/docker/mongodb-enterprise-appdb-database/4.4/ubi/Dockerfile b/docker/mongodb-enterprise-appdb-database/4.4/ubi/Dockerfile new file mode 100644 index 000000000..a93fffc68 --- /dev/null +++ b/docker/mongodb-enterprise-appdb-database/4.4/ubi/Dockerfile @@ -0,0 +1,91 @@ +FROM registry.access.redhat.com/ubi8/ubi + +ARG MONGO_VERSION + +LABEL name="MongoDB Enterprise AppDB Database" \ + version=${MONGO_VERSION} \ + summary="MongoDB Enterprise AppDB Database" \ + description="MongoDB Enterprise AppDB Database" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +ADD licenses /licenses + +# add our user and group first to make sure their IDs get assigned consistently, regardless of whatever dependencies get added +RUN groupadd -r mongodb && useradd -r -g mongodb mongodb + +#TODO add numactl + +RUN set -eux; \ + yum install -y --disableplugin=subscription-manager \ + ca-certificates \ + jq \ + ; \ + if ! command -v ps > /dev/null; then \ + yum install -y --disableplugin=subscription-managers procps; \ + fi; + +# grab gosu for easy step-down from root (https://github.com/tianon/gosu/releases) +ENV GOSU_VERSION 1.14 +# grab "js-yaml" for parsing mongod's YAML config files (https://github.com/nodeca/js-yaml/releases) +ENV JSYAML_VERSION 3.13.1 + +RUN set -ex; \ + \ + yum install -y --disableplugin=subscription-manager \ + wget \ + ; \ + if ! command -v gpg > /dev/null; then \ + yum install -y --disableplugin=subscription-manager gnupg dirmngr; \ + elif gpg --version | grep -q '^gpg (GnuPG) 1\.'; then \ +# "This package provides support for HKPS keyservers." (GnuPG 1.x only) + yum install -y --disableplugin=subscription-manager gnupg-curl; \ + fi; \ + \ + dpkgArch="$(arch| sed 's/x86_64/amd64/')"; \ + wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \ + wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \ + export GNUPGHOME="$(mktemp -d)"; \ + gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \ + gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \ + command -v gpgconf && gpgconf --kill all || :; \ + rm -r "$GNUPGHOME" /usr/local/bin/gosu.asc; \ + \ + wget -O /js-yaml.js "https://github.com/nodeca/js-yaml/raw/${JSYAML_VERSION}/dist/js-yaml.js"; \ + +# smoke test + chmod +x /usr/local/bin/gosu; \ + gosu --version; \ + gosu nobody true + +RUN mkdir /docker-entrypoint-initdb.d + + +# # Allow build-time overrides (eg. to build image with MongoDB Enterprise version) +# # Options for MONGO_PACKAGE: mongodb-org OR mongodb-enterprise +# # Options for MONGO_REPO: repo.mongodb.org OR repo.mongodb.com +# # Example: docker build --build-arg MONGO_PACKAGE=mongodb-enterprise --build-arg MONGO_REPO=repo.mongodb.com . +ARG MONGO_PACKAGE=mongodb-org +ARG MONGO_REPO=repo.mongodb.org +ENV MONGO_PACKAGE=${MONGO_PACKAGE} MONGO_REPO=${MONGO_REPO} + +# TODO put everything in a single dockerfile +ENV MONGO_MAJOR 4.4 + +COPY 4.4/ubi/mongodb-org-4.4.repo /etc/yum.repos.d/mongodb-org-4.4.repo + +RUN cat /etc/yum.repos.d/mongodb-org-4.4.repo + +RUN yum install -y mongodb-enterprise-${MONGO_VERSION} mongodb-enterprise-server-${MONGO_VERSION} mongodb-enterprise-shell-${MONGO_VERSION} mongodb-enterprise-mongos-${MONGO_VERSION} mongodb-enterprise-tools-${MONGO_VERSION} + +RUN echo "exclude=mongodb-org,mongodb-org-server,mongodb-org-shell,mongodb-org-mongos,mongodb-org-tools" >> /etc/yum.conf +RUN mkdir -p /data/db /data/configdb \ + && chown -R mongodb:mongodb /data/db /data/configdb +VOLUME /data/db /data/configdb + +COPY docker-entrypoint.sh /usr/local/bin/ +ENTRYPOINT ["docker-entrypoint.sh"] + +EXPOSE 27017 +CMD ["mongod"] diff --git a/docker/mongodb-enterprise-appdb-database/4.4/ubi/mongodb-org-4.4.repo b/docker/mongodb-enterprise-appdb-database/4.4/ubi/mongodb-org-4.4.repo new file mode 100644 index 000000000..0a81c4c40 --- /dev/null +++ b/docker/mongodb-enterprise-appdb-database/4.4/ubi/mongodb-org-4.4.repo @@ -0,0 +1,6 @@ +[mongodb-enterprise-4.4] +name=MongoDB Enterprise Repository +baseurl=https://repo.mongodb.com/yum/redhat/$releasever/mongodb-enterprise/4.4/$basearch/ +gpgcheck=1 +enabled=1 +gpgkey=https://www.mongodb.org/static/pgp/server-4.4.asc diff --git a/docker/mongodb-enterprise-appdb-database/4.4/ubuntu/Dockerfile b/docker/mongodb-enterprise-appdb-database/4.4/ubuntu/Dockerfile new file mode 100644 index 000000000..1d963a27c --- /dev/null +++ b/docker/mongodb-enterprise-appdb-database/4.4/ubuntu/Dockerfile @@ -0,0 +1,125 @@ +FROM ubuntu:xenial-20210416 + +ARG MONGO_VERSION + +LABEL name="MongoDB Enterprise AppDB Database" \ + version=${MONGO_VERSION} \ + summary="MongoDB Enterprise AppDB Database" \ + description="MongoDB Enterprise AppDB Database" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +ADD licenses /licenses + +# add our user and group first to make sure their IDs get assigned consistently, regardless of whatever dependencies get added +RUN groupadd -r mongodb && useradd -r -g mongodb mongodb + +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + jq \ + numactl \ + ; \ + if ! command -v ps > /dev/null; then \ + apt-get install -y --no-install-recommends procps; \ + fi; \ + rm -rf /var/lib/apt/lists/* + +# grab gosu for easy step-down from root (https://github.com/tianon/gosu/releases) +ENV GOSU_VERSION 1.14 +# grab "js-yaml" for parsing mongod's YAML config files (https://github.com/nodeca/js-yaml/releases) +ENV JSYAML_VERSION 3.13.1 + +RUN mkdir ~/.gnupg +RUN echo "disable-ipv6" >> ~/.gnupg/dirmngr.conf +RUN set -ex; \ + \ + savedAptMark="$(apt-mark showmanual)"; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + wget \ + ; \ + if ! command -v gpg > /dev/null; then \ + apt-get install -y --no-install-recommends gnupg dirmngr; \ + savedAptMark="$savedAptMark gnupg dirmngr"; \ + elif gpg --version | grep -q '^gpg (GnuPG) 1\.'; then \ +# "This package provides support for HKPS keyservers." (GnuPG 1.x only) + apt-get install -y --no-install-recommends gnupg-curl; \ + fi; \ + rm -rf /var/lib/apt/lists/*; \ + \ + dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \ + wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \ + wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \ + export GNUPGHOME="$(mktemp -d)"; \ + gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \ + gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \ + command -v gpgconf && gpgconf --kill all || :; \ + rm -r "$GNUPGHOME" /usr/local/bin/gosu.asc; \ + \ + wget -O /js-yaml.js "https://github.com/nodeca/js-yaml/raw/${JSYAML_VERSION}/dist/js-yaml.js"; \ +# TODO some sort of download verification here + \ + apt-mark auto '.*' > /dev/null; \ + apt-mark manual $savedAptMark > /dev/null; \ + apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false; \ + \ +# smoke test + chmod +x /usr/local/bin/gosu; \ + gosu --version; \ + gosu nobody true + +RUN mkdir /docker-entrypoint-initdb.d + +ENV GPG_KEYS 20691EEC35216C63CAF66CE1656408E390CFB1F5 +RUN set -ex; \ + export GNUPGHOME="$(mktemp -d)"; \ + for key in $GPG_KEYS; do \ + gpg --batch --keyserver keyserver.ubuntu.com --recv-keys "$key"; \ + done; \ + gpg --batch --export $GPG_KEYS > /etc/apt/trusted.gpg.d/mongodb.gpg; \ + command -v gpgconf && gpgconf --kill all || :; \ + rm -r "$GNUPGHOME"; \ + apt-key list + +# Allow build-time overrides (eg. to build image with MongoDB Enterprise version) +# Options for MONGO_PACKAGE: mongodb-org OR mongodb-enterprise +# Options for MONGO_REPO: repo.mongodb.org OR repo.mongodb.com +# Example: docker build --build-arg MONGO_PACKAGE=mongodb-enterprise --build-arg MONGO_REPO=repo.mongodb.com . +ARG MONGO_PACKAGE=mongodb-org +ARG MONGO_REPO=repo.mongodb.org +ENV MONGO_PACKAGE=${MONGO_PACKAGE} MONGO_REPO=${MONGO_REPO} + +ENV MONGO_MAJOR 4.4 + +# bashbrew-architectures:amd64 arm64v8 s390x +RUN echo "deb http://$MONGO_REPO/apt/ubuntu xenial/${MONGO_PACKAGE%-unstable}/$MONGO_MAJOR multiverse" | tee "/etc/apt/sources.list.d/${MONGO_PACKAGE%-unstable}.list" + +RUN set -x \ +# installing "mongodb-enterprise" pulls in "tzdata" which prompts for input + && export DEBIAN_FRONTEND=noninteractive \ + && apt-get update \ +# starting with MongoDB 4.3 (and backported to 4.0 and 4.2 *and* 3.6??), the postinst for server includes an unconditional "systemctl daemon-reload" (and we don't have anything for "systemctl" to talk to leading to dbus errors and failed package installs) + && ln -s /bin/true /usr/local/bin/systemctl \ + && apt-get install -y \ + ${MONGO_PACKAGE}=$MONGO_VERSION \ + ${MONGO_PACKAGE}-server=$MONGO_VERSION \ + ${MONGO_PACKAGE}-shell=$MONGO_VERSION \ + ${MONGO_PACKAGE}-mongos=$MONGO_VERSION \ + ${MONGO_PACKAGE}-tools=$MONGO_VERSION \ + && rm -f /usr/local/bin/systemctl \ + && rm -rf /var/lib/apt/lists/* \ + && rm -rf /var/lib/mongodb \ + && mv /etc/mongod.conf /etc/mongod.conf.orig + +RUN mkdir -p /data/db /data/configdb \ + && chown -R mongodb:mongodb /data/db /data/configdb +VOLUME /data/db /data/configdb + +COPY docker-entrypoint.sh /usr/local/bin/ +ENTRYPOINT ["docker-entrypoint.sh"] + +EXPOSE 27017 +CMD ["mongod"] diff --git a/docker/mongodb-enterprise-appdb-database/5.0/ubi/Dockerfile b/docker/mongodb-enterprise-appdb-database/5.0/ubi/Dockerfile new file mode 100644 index 000000000..066e88709 --- /dev/null +++ b/docker/mongodb-enterprise-appdb-database/5.0/ubi/Dockerfile @@ -0,0 +1,85 @@ +FROM registry.access.redhat.com/ubi8/ubi + +ARG MONGO_VERSION + +LABEL name="MongoDB Enterprise AppDB Database" \ + version=${MONGO_VERSION} \ + summary="MongoDB Enterprise AppDB Database" \ + description="MongoDB Enterprise AppDB Database" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +ADD licenses /licenses + +# add our user and group first to make sure their IDs get assigned consistently, regardless of whatever dependencies get added +RUN groupadd -r mongodb && useradd -r -g mongodb mongodb + +RUN set -eux; \ + yum install -y \ + ca-certificates \ + jq \ + ; \ + if ! command -v ps > /dev/null; then \ + yum install -y procps; \ + fi; + +# grab gosu for easy step-down from root (https://github.com/tianon/gosu/releases) +ENV GOSU_VERSION 1.14 +# grab "js-yaml" for parsing mongod's YAML config files (https://github.com/nodeca/js-yaml/releases) +ENV JSYAML_VERSION 3.13.1 + +RUN set -ex; \ + \ + yum install -y \ + wget \ + ; \ + if ! command -v gpg > /dev/null; then \ + yum install -y gnupg dirmngr; \ + elif gpg --version | grep -q '^gpg (GnuPG) 1\.'; then \ +# "This package provides support for HKPS keyservers." (GnuPG 1.x only) + yum install -y gnupg-curl; \ + fi; \ + \ + dpkgArch="$(arch| sed 's/x86_64/amd64/')"; \ + wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \ + wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \ + export GNUPGHOME="$(mktemp -d)"; \ + gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \ + gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \ + command -v gpgconf && gpgconf --kill all || :; \ + rm -r "$GNUPGHOME" /usr/local/bin/gosu.asc; \ + \ + wget -O /js-yaml.js "https://github.com/nodeca/js-yaml/raw/${JSYAML_VERSION}/dist/js-yaml.js"; \ +# smoke test + chmod +x /usr/local/bin/gosu; \ + gosu --version; \ + gosu nobody true + +RUN mkdir /docker-entrypoint-initdb.d + +# Allow build-time overrides (eg. to build image with MongoDB Enterprise version) +# Options for MONGO_PACKAGE: mongodb-org OR mongodb-enterprise +# Options for MONGO_REPO: repo.mongodb.org OR repo.mongodb.com +# Example: docker build --build-arg MONGO_PACKAGE=mongodb-enterprise --build-arg MONGO_REPO=repo.mongodb.com . +ARG MONGO_PACKAGE=mongodb-org +ARG MONGO_REPO=repo.mongodb.org +ENV MONGO_PACKAGE=${MONGO_PACKAGE} MONGO_REPO=${MONGO_REPO} + +ENV MONGO_MAJOR 5.0 + +COPY 5.0/ubi/mongodb-org-5.0.repo /etc/yum.repos.d/mongodb-org-5.0.repo +# 08/03/2021, https://github.com/mongodb/mongo/tree/6d9ec525e78465dcecadcff99cce953d380fedc8 + +RUN yum install -y mongodb-enterprise-${MONGO_VERSION} mongodb-enterprise-server-${MONGO_VERSION} mongodb-enterprise-shell-${MONGO_VERSION} mongodb-enterprise-mongos-${MONGO_VERSION} mongodb-enterprise-tools-${MONGO_VERSION} + +RUN echo "exclude=mongodb-org,mongodb-org-server,mongodb-org-shell,mongodb-org-mongos,mongodb-org-tools" >> /etc/yum.conf +RUN mkdir -p /data/db /data/configdb \ + && chown -R mongodb:mongodb /data/db /data/configdb +VOLUME /data/db /data/configdb + +COPY docker-entrypoint.sh /usr/local/bin/ +ENTRYPOINT ["docker-entrypoint.sh"] + +EXPOSE 27017 +CMD ["mongod"] diff --git a/docker/mongodb-enterprise-appdb-database/5.0/ubi/mongodb-org-5.0.repo b/docker/mongodb-enterprise-appdb-database/5.0/ubi/mongodb-org-5.0.repo new file mode 100644 index 000000000..76946402d --- /dev/null +++ b/docker/mongodb-enterprise-appdb-database/5.0/ubi/mongodb-org-5.0.repo @@ -0,0 +1,6 @@ +[mongodb-enterprise-5.0] +name=MongoDB Enterprise Repository +baseurl=https://repo.mongodb.com/yum/redhat/$releasever/mongodb-enterprise/5.0/$basearch/ +gpgcheck=1 +enabled=1 +gpgkey=https://www.mongodb.org/static/pgp/server-5.0.asc diff --git a/docker/mongodb-enterprise-appdb-database/5.0/ubuntu/Dockerfile b/docker/mongodb-enterprise-appdb-database/5.0/ubuntu/Dockerfile new file mode 100644 index 000000000..baf1e8a7d --- /dev/null +++ b/docker/mongodb-enterprise-appdb-database/5.0/ubuntu/Dockerfile @@ -0,0 +1,123 @@ +FROM ubuntu:focal + +ARG MONGO_VERSION + +LABEL name="MongoDB Enterprise AppDB Database" \ + version=${MONGO_VERSION} \ + summary="MongoDB Enterprise AppDB Database" \ + description="MongoDB Enterprise AppDB Database" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +ADD licenses /licenses + +# add our user and group first to make sure their IDs get assigned consistently, regardless of whatever dependencies get added +RUN groupadd -r mongodb && useradd -r -g mongodb mongodb + +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + jq \ + numactl \ + ; \ + if ! command -v ps > /dev/null; then \ + apt-get install -y --no-install-recommends procps; \ + fi; \ + rm -rf /var/lib/apt/lists/* + +# grab gosu for easy step-down from root (https://github.com/tianon/gosu/releases) +ENV GOSU_VERSION 1.14 +# grab "js-yaml" for parsing mongod's YAML config files (https://github.com/nodeca/js-yaml/releases) +ENV JSYAML_VERSION 3.13.1 + +RUN set -ex; \ + \ + savedAptMark="$(apt-mark showmanual)"; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + wget \ + ; \ + if ! command -v gpg > /dev/null; then \ + apt-get install -y --no-install-recommends gnupg dirmngr; \ + savedAptMark="$savedAptMark gnupg dirmngr"; \ + elif gpg --version | grep -q '^gpg (GnuPG) 1\.'; then \ +# "This package provides support for HKPS keyservers." (GnuPG 1.x only) + apt-get install -y --no-install-recommends gnupg-curl; \ + fi; \ + rm -rf /var/lib/apt/lists/*; \ + \ + dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \ + wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \ + wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \ + export GNUPGHOME="$(mktemp -d)"; \ + gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \ + gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \ + command -v gpgconf && gpgconf --kill all || :; \ + rm -r "$GNUPGHOME" /usr/local/bin/gosu.asc; \ + \ + wget -O /js-yaml.js "https://github.com/nodeca/js-yaml/raw/${JSYAML_VERSION}/dist/js-yaml.js"; \ +# TODO some sort of download verification here + \ + apt-mark auto '.*' > /dev/null; \ + apt-mark manual $savedAptMark > /dev/null; \ + apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false; \ + \ +# smoke test + chmod +x /usr/local/bin/gosu; \ + gosu --version; \ + gosu nobody true + +RUN mkdir /docker-entrypoint-initdb.d + +RUN set -ex; \ + export GNUPGHOME="$(mktemp -d)"; \ + set -- 'F5679A222C647C87527C2F8CB00A0BD1E2C63C11'; \ + for key; do \ + gpg --batch --keyserver keyserver.ubuntu.com --recv-keys "$key"; \ + done; \ + gpg --batch --export "$@" > /etc/apt/trusted.gpg.d/mongodb.gpg; \ + command -v gpgconf && gpgconf --kill all || :; \ + rm -r "$GNUPGHOME"; \ + apt-key list + +# Allow build-time overrides (eg. to build image with MongoDB Enterprise version) +# Options for MONGO_PACKAGE: mongodb-org OR mongodb-enterprise +# Options for MONGO_REPO: repo.mongodb.org OR repo.mongodb.com +# Example: docker build --build-arg MONGO_PACKAGE=mongodb-enterprise --build-arg MONGO_REPO=repo.mongodb.com . +ARG MONGO_PACKAGE=mongodb-org +ARG MONGO_REPO=repo.mongodb.org +ENV MONGO_PACKAGE=${MONGO_PACKAGE} MONGO_REPO=${MONGO_REPO} + +ENV MONGO_MAJOR 5.0 +RUN echo "deb http://$MONGO_REPO/apt/ubuntu focal/${MONGO_PACKAGE%-unstable}/$MONGO_MAJOR multiverse" | tee "/etc/apt/sources.list.d/${MONGO_PACKAGE%-unstable}.list" + +# 08/03/2021, https://github.com/mongodb/mongo/tree/6d9ec525e78465dcecadcff99cce953d380fedc8 + +RUN set -x \ +# installing "mongodb-enterprise" pulls in "tzdata" which prompts for input + && export DEBIAN_FRONTEND=noninteractive \ + && apt-get update \ +# starting with MongoDB 4.3 (and backported to 4.0 and 4.2 *and* 3.6??), the postinst for server includes an unconditional "systemctl daemon-reload" (and we don't have anything for "systemctl" to talk to leading to dbus errors and failed package installs) + && ln -s /bin/true /usr/local/bin/systemctl \ + && apt-get install -y \ + ${MONGO_PACKAGE}=$MONGO_VERSION \ + ${MONGO_PACKAGE}-server=$MONGO_VERSION \ + ${MONGO_PACKAGE}-shell=$MONGO_VERSION \ + ${MONGO_PACKAGE}-mongos=$MONGO_VERSION \ + ${MONGO_PACKAGE}-tools=$MONGO_VERSION \ + && rm -f /usr/local/bin/systemctl \ + && rm -rf /var/lib/apt/lists/* \ + && rm -rf /var/lib/mongodb \ + && mv /etc/mongod.conf /etc/mongod.conf.orig + +RUN mkdir -p /data/db /data/configdb \ + && chown -R mongodb:mongodb /data/db /data/configdb +VOLUME /data/db /data/configdb + +COPY docker-entrypoint.sh /usr/local/bin/ +ENTRYPOINT ["docker-entrypoint.sh"] + +EXPOSE 27017 +CMD ["mongod"] diff --git a/docker/mongodb-enterprise-appdb-database/README.md b/docker/mongodb-enterprise-appdb-database/README.md new file mode 100644 index 000000000..a5f332d2f --- /dev/null +++ b/docker/mongodb-enterprise-appdb-database/README.md @@ -0,0 +1,8 @@ +Build enterprise images with + +```bash +docker build -f path/to/dockerfile --build-arg MONGO_PACKAGE=mongodb-enterprise --build-arg MONGO_REPO=repo.mongodb.com --build-arg MONGO_VERSION=4.0.20 . +``` + +within the given directory. + diff --git a/docker/mongodb-enterprise-appdb-database/build_and_push_appdb_database_images.sh b/docker/mongodb-enterprise-appdb-database/build_and_push_appdb_database_images.sh new file mode 100755 index 000000000..cf01f8afc --- /dev/null +++ b/docker/mongodb-enterprise-appdb-database/build_and_push_appdb_database_images.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -Eeou pipefail + +_44_versions=$(jq -rc '.supportedImages."appdb-database".versions[] | select(test("^4.4"))' < ../../release.json | sed 's/-ent//g' | tr '\n' ' ') +_50_versions=$(jq -rc '.supportedImages."appdb-database".versions[] | select(test("^5.0"))' < ../../release.json | sed 's/-ent//g' | tr '\n' ' ') + +echo "4.4 versions: ${_44_versions}" +echo "5.0 versions: ${_50_versions}" + +build_id="b$(date '+%Y%m%dT000000Z')" + +missing_versions="" + +append_missing_version() { + # shellcheck disable=SC2181 + if [ $? -ne 0 ]; then + missing_versions+="${1} on ${2}"$'\n' + fi +} + +for version in $_44_versions; do + echo "Building version ${version}" + docker build \ + -f 4.4/ubuntu/Dockerfile \ + --build-arg MONGO_PACKAGE=mongodb-enterprise \ + --build-arg "MONGO_VERSION=${version}" \ + --build-arg MONGO_REPO=repo.mongodb.com \ + -t "quay.io/mongodb/mongodb-enterprise-appdb-database:${version}-ent-${build_id}" \ + -t "quay.io/mongodb/mongodb-enterprise-appdb-database:${version}-ent" . + append_missing_version "${version}" "ubuntu" + + docker push "quay.io/mongodb/mongodb-enterprise-appdb-database:${version}-ent-${build_id}" + docker push "quay.io/mongodb/mongodb-enterprise-appdb-database:${version}-ent" + + docker build \ + -f 4.4/ubi/Dockerfile \ + --build-arg MONGO_PACKAGE=mongodb-enterprise \ + --build-arg "MONGO_VERSION=${version}" \ + --build-arg MONGO_REPO=repo.mongodb.com \ + -t "quay.io/mongodb/mongodb-enterprise-appdb-database-ubi:${version}-ent-${build_id}" \ + -t "quay.io/mongodb/mongodb-enterprise-appdb-database-ubi:${version}-ent" . + append_missing_version "${version}" "ubi" + + docker push "quay.io/mongodb/mongodb-enterprise-appdb-database-ubi:${version}-ent-${build_id}" + docker push "quay.io/mongodb/mongodb-enterprise-appdb-database-ubi:${version}-ent" +done + +for version in $_50_versions; do + echo "Building version ${version}" + docker build \ + -f 5.0/ubuntu/Dockerfile \ + --build-arg MONGO_PACKAGE=mongodb-enterprise \ + --build-arg "MONGO_VERSION=${version}" \ + --build-arg MONGO_REPO=repo.mongodb.com \ + -t "quay.io/mongodb/mongodb-enterprise-appdb-database:${version}-ent-${build_id}" \ + -t "quay.io/mongodb/mongodb-enterprise-appdb-database:${version}-ent" . + append_missing_version "${version}" "ubuntu" + + docker push "quay.io/mongodb/mongodb-enterprise-appdb-database:${version}-ent-${build_id}" + docker push "quay.io/mongodb/mongodb-enterprise-appdb-database:${version}-ent" + + docker build \ + -f 5.0/ubi/Dockerfile \ + --build-arg MONGO_PACKAGE=mongodb-enterprise \ + --build-arg "MONGO_VERSION=${version}" \ + --build-arg MONGO_REPO=repo.mongodb.com \ + -t "quay.io/mongodb/mongodb-enterprise-appdb-database-ubi:${version}-ent-${build_id}" \ + -t "quay.io/mongodb/mongodb-enterprise-appdb-database-ubi:${version}-ent" . + append_missing_version "${version}" "ubi" + + docker push "quay.io/mongodb/mongodb-enterprise-appdb-database-ubi:${version}-ent-${build_id}" + docker push "quay.io/mongodb/mongodb-enterprise-appdb-database-ubi:${version}-ent" +done + +echo "Missing versions" +echo "${missing_versions}" diff --git a/docker/mongodb-enterprise-appdb-database/docker-entrypoint.sh b/docker/mongodb-enterprise-appdb-database/docker-entrypoint.sh new file mode 100644 index 000000000..db207bf11 --- /dev/null +++ b/docker/mongodb-enterprise-appdb-database/docker-entrypoint.sh @@ -0,0 +1,386 @@ +#!/bin/bash +set -Eeuo pipefail + +if [ "${1:0:1}" = '-' ]; then + set -- mongod "$@" +fi + +originalArgOne="$1" + +# allow the container to be started with `--user` +# all mongo* commands should be dropped to the correct user +if [[ "$originalArgOne" == mongo* ]] && [ "$(id -u)" = '0' ]; then + if [ "$originalArgOne" = 'mongod' ]; then + find /data/configdb /data/db \! -user mongodb -exec chown mongodb '{}' + + fi + + # make sure we can write to stdout and stderr as "mongodb" + # (for our "initdb" code later; see "--logpath" below) + chown --dereference mongodb "/proc/$$/fd/1" "/proc/$$/fd/2" || : + # ignore errors thanks to https://github.com/docker-library/mongo/issues/149 + + exec gosu mongodb "$BASH_SOURCE" "$@" +fi + + +# you should use numactl to start your mongod instances, including the config servers, mongos instances, and any clients. +# https://docs.mongodb.com/manual/administration/production-notes/#configuring-numa-on-linux +if [[ "$originalArgOne" == mongo* ]]; then + numa='numactl --interleave=all' + if $numa true &> /dev/null; then + set -- $numa "$@" + fi +fi + +# usage: file_env VAR [DEFAULT] +# ie: file_env 'XYZ_DB_PASSWORD' 'example' +# (will allow for "$XYZ_DB_PASSWORD_FILE" to fill in the value of +# "$XYZ_DB_PASSWORD" from a file, especially for Docker's secrets feature) +file_env() { + local var="$1" + local fileVar="${var}_FILE" + local def="${2:-}" + if [ "${!var:-}" ] && [ "${!fileVar:-}" ]; then + echo >&2 "error: both $var and $fileVar are set (but are exclusive)" + exit 1 + fi + local val="$def" + if [ "${!var:-}" ]; then + val="${!var}" + elif [ "${!fileVar:-}" ]; then + val="$(< "${!fileVar}")" + fi + export "$var"="$val" + unset "$fileVar" +} + +# see https://github.com/docker-library/mongo/issues/147 (mongod is picky about duplicated arguments) +_mongod_hack_have_arg() { + local checkArg="$1"; shift + local arg + for arg; do + case "$arg" in + "$checkArg"|"$checkArg"=*) + return 0 + ;; + esac + done + return 1 +} +# _mongod_hack_get_arg_val '--some-arg' "$@" +_mongod_hack_get_arg_val() { + local checkArg="$1"; shift + while [ "$#" -gt 0 ]; do + local arg="$1"; shift + case "$arg" in + "$checkArg") + echo "$1" + return 0 + ;; + "$checkArg"=*) + echo "${arg#$checkArg=}" + return 0 + ;; + esac + done + return 1 +} +declare -a mongodHackedArgs +# _mongod_hack_ensure_arg '--some-arg' "$@" +# set -- "${mongodHackedArgs[@]}" +_mongod_hack_ensure_arg() { + local ensureArg="$1"; shift + mongodHackedArgs=( "$@" ) + if ! _mongod_hack_have_arg "$ensureArg" "$@"; then + mongodHackedArgs+=( "$ensureArg" ) + fi +} +# _mongod_hack_ensure_no_arg '--some-unwanted-arg' "$@" +# set -- "${mongodHackedArgs[@]}" +_mongod_hack_ensure_no_arg() { + local ensureNoArg="$1"; shift + mongodHackedArgs=() + while [ "$#" -gt 0 ]; do + local arg="$1"; shift + if [ "$arg" = "$ensureNoArg" ]; then + continue + fi + mongodHackedArgs+=( "$arg" ) + done +} +# _mongod_hack_ensure_no_arg '--some-unwanted-arg' "$@" +# set -- "${mongodHackedArgs[@]}" +_mongod_hack_ensure_no_arg_val() { + local ensureNoArg="$1"; shift + mongodHackedArgs=() + while [ "$#" -gt 0 ]; do + local arg="$1"; shift + case "$arg" in + "$ensureNoArg") + shift # also skip the value + continue + ;; + "$ensureNoArg"=*) + # value is already included + continue + ;; + esac + mongodHackedArgs+=( "$arg" ) + done +} +# _mongod_hack_ensure_arg_val '--some-arg' 'some-val' "$@" +# set -- "${mongodHackedArgs[@]}" +_mongod_hack_ensure_arg_val() { + local ensureArg="$1"; shift + local ensureVal="$1"; shift + _mongod_hack_ensure_no_arg_val "$ensureArg" "$@" + mongodHackedArgs+=( "$ensureArg" "$ensureVal" ) +} + +# _js_escape 'some "string" value' +_js_escape() { + jq --null-input --arg 'str' "$1" '$str' +} + +: "${TMPDIR:=/tmp}" +jsonConfigFile="$TMPDIR/docker-entrypoint-config.json" +tempConfigFile="$TMPDIR/docker-entrypoint-temp-config.json" +_parse_config() { + if [ -s "$tempConfigFile" ]; then + return 0 + fi + + local configPath + if configPath="$(_mongod_hack_get_arg_val --config "$@")" && [ -s "$configPath" ]; then + # if --config is specified, parse it into a JSON file so we can remove a few problematic keys (especially SSL-related keys) + # see https://docs.mongodb.com/manual/reference/configuration-options/ + if grep -vEm1 '^[[:space:]]*(#|$)' "$configPath" | grep -qE '^[[:space:]]*[^=:]+[[:space:]]*='; then + # if the first non-comment/non-blank line of the config file looks like "foo = ...", this is probably the 2.4 and older "ini-style config format" + # https://docs.mongodb.com/v2.4/reference/configuration-options/ + # https://docs.mongodb.com/v2.6/reference/configuration-options/ + # https://github.com/mongodb/mongo/blob/r4.4.2/src/mongo/util/options_parser/options_parser.cpp#L1359-L1375 + # https://stackoverflow.com/a/25518018/433558 + echo >&2 + echo >&2 "WARNING: it appears that '$configPath' is in the older INI-style format (replaced by YAML in MongoDB 2.6)" + echo >&2 ' This script does not parse the older INI-style format, and thus will ignore it.' + echo >&2 + return 1 + fi + mongo --norc --nodb --quiet --eval "load('/js-yaml.js'); printjson(jsyaml.load(cat($(_js_escape "$configPath"))))" > "$jsonConfigFile" + if [ "$(head -c1 "$jsonConfigFile")" != '{' ] || [ "$(tail -c2 "$jsonConfigFile")" != '}' ]; then + # if the file doesn't start with "{" and end with "}", it's *probably* an error ("uncaught exception: YAMLException: foo" for example), so we should print it out + echo >&2 'error: unexpected "js-yaml.js" output while parsing config:' + cat >&2 "$jsonConfigFile" + exit 1 + fi + jq 'del(.systemLog, .processManagement, .net, .security)' "$jsonConfigFile" > "$tempConfigFile" + return 0 + fi + + return 1 +} +dbPath= +_dbPath() { + if [ -n "$dbPath" ]; then + echo "$dbPath" + return + fi + + if ! dbPath="$(_mongod_hack_get_arg_val --dbpath "$@")"; then + if _parse_config "$@"; then + dbPath="$(jq -r '.storage.dbPath // empty' "$jsonConfigFile")" + fi + fi + + if [ -z "$dbPath" ]; then + if _mongod_hack_have_arg --configsvr "$@" || { + _parse_config "$@" \ + && clusterRole="$(jq -r '.sharding.clusterRole // empty' "$jsonConfigFile")" \ + && [ "$clusterRole" = 'configsvr' ] + }; then + # if running as config server, then the default dbpath is /data/configdb + # https://docs.mongodb.com/manual/reference/program/mongod/#cmdoption-mongod-configsvr + dbPath=/data/configdb + fi + fi + + : "${dbPath:=/data/db}" + + echo "$dbPath" +} + +if [ "$originalArgOne" = 'mongod' ]; then + file_env 'MONGO_INITDB_ROOT_USERNAME' + file_env 'MONGO_INITDB_ROOT_PASSWORD' + # pre-check a few factors to see if it's even worth bothering with initdb + shouldPerformInitdb= + if [ "$MONGO_INITDB_ROOT_USERNAME" ] && [ "$MONGO_INITDB_ROOT_PASSWORD" ]; then + # if we have a username/password, let's set "--auth" + _mongod_hack_ensure_arg '--auth' "$@" + set -- "${mongodHackedArgs[@]}" + shouldPerformInitdb='true' + elif [ "$MONGO_INITDB_ROOT_USERNAME" ] || [ "$MONGO_INITDB_ROOT_PASSWORD" ]; then + cat >&2 <<-'EOF' + + error: missing 'MONGO_INITDB_ROOT_USERNAME' or 'MONGO_INITDB_ROOT_PASSWORD' + both must be specified for a user to be created + + EOF + exit 1 + fi + + if [ -z "$shouldPerformInitdb" ]; then + # if we've got any /docker-entrypoint-initdb.d/* files to parse later, we should initdb + for f in /docker-entrypoint-initdb.d/*; do + case "$f" in + *.sh|*.js) # this should match the set of files we check for below + shouldPerformInitdb="$f" + break + ;; + esac + done + fi + + # check for a few known paths (to determine whether we've already initialized and should thus skip our initdb scripts) + if [ -n "$shouldPerformInitdb" ]; then + dbPath="$(_dbPath "$@")" + for path in \ + "$dbPath/WiredTiger" \ + "$dbPath/journal" \ + "$dbPath/local.0" \ + "$dbPath/storage.bson" \ + ; do + if [ -e "$path" ]; then + shouldPerformInitdb= + break + fi + done + fi + + if [ -n "$shouldPerformInitdb" ]; then + mongodHackedArgs=( "$@" ) + if _parse_config "$@"; then + _mongod_hack_ensure_arg_val --config "$tempConfigFile" "${mongodHackedArgs[@]}" + fi + _mongod_hack_ensure_arg_val --bind_ip 127.0.0.1 "${mongodHackedArgs[@]}" + _mongod_hack_ensure_arg_val --port 27017 "${mongodHackedArgs[@]}" + _mongod_hack_ensure_no_arg --bind_ip_all "${mongodHackedArgs[@]}" + + # remove "--auth" and "--replSet" for our initial startup (see https://docs.mongodb.com/manual/tutorial/enable-authentication/#start-mongodb-without-access-control) + # https://github.com/docker-library/mongo/issues/211 + _mongod_hack_ensure_no_arg --auth "${mongodHackedArgs[@]}" + # "keyFile implies security.authorization" + # https://docs.mongodb.com/manual/reference/configuration-options/#mongodb-setting-security.keyFile + _mongod_hack_ensure_no_arg_val --keyFile "${mongodHackedArgs[@]}" + if [ "$MONGO_INITDB_ROOT_USERNAME" ] && [ "$MONGO_INITDB_ROOT_PASSWORD" ]; then + _mongod_hack_ensure_no_arg_val --replSet "${mongodHackedArgs[@]}" + fi + + # "BadValue: need sslPEMKeyFile when SSL is enabled" vs "BadValue: need to enable SSL via the sslMode flag when using SSL configuration parameters" + tlsMode='disabled' + if _mongod_hack_have_arg '--tlsCertificateKeyFile' "$@"; then + tlsMode='allowTLS' + elif _mongod_hack_have_arg '--sslPEMKeyFile' "$@"; then + tlsMode='allowSSL' + fi + # 4.2 switched all configuration/flag names from "SSL" to "TLS" + if [ "$tlsMode" = 'allowTLS' ] || mongod --help 2>&1 | grep -q -- ' --tlsMode '; then + _mongod_hack_ensure_arg_val --tlsMode "$tlsMode" "${mongodHackedArgs[@]}" + else + _mongod_hack_ensure_arg_val --sslMode "$tlsMode" "${mongodHackedArgs[@]}" + fi + + if stat "/proc/$$/fd/1" > /dev/null && [ -w "/proc/$$/fd/1" ]; then + # https://github.com/mongodb/mongo/blob/38c0eb538d0fd390c6cb9ce9ae9894153f6e8ef5/src/mongo/db/initialize_server_global_state.cpp#L237-L251 + # https://github.com/docker-library/mongo/issues/164#issuecomment-293965668 + _mongod_hack_ensure_arg_val --logpath "/proc/$$/fd/1" "${mongodHackedArgs[@]}" + else + initdbLogPath="$(_dbPath "$@")/docker-initdb.log" + echo >&2 "warning: initdb logs cannot write to '/proc/$$/fd/1', so they are in '$initdbLogPath' instead" + _mongod_hack_ensure_arg_val --logpath "$initdbLogPath" "${mongodHackedArgs[@]}" + fi + _mongod_hack_ensure_arg --logappend "${mongodHackedArgs[@]}" + + pidfile="$TMPDIR/docker-entrypoint-temp-mongod.pid" + rm -f "$pidfile" + _mongod_hack_ensure_arg_val --pidfilepath "$pidfile" "${mongodHackedArgs[@]}" + + "${mongodHackedArgs[@]}" --fork + + mongo=( mongo --host 127.0.0.1 --port 27017 --quiet ) + + # check to see that our "mongod" actually did start up (catches "--help", "--version", MongoDB 3.2 being silly, slow prealloc, etc) + # https://jira.mongodb.org/browse/SERVER-16292 + tries=30 + while true; do + if ! { [ -s "$pidfile" ] && ps "$(< "$pidfile")" &> /dev/null; }; then + # bail ASAP if "mongod" isn't even running + echo >&2 + echo >&2 "error: $originalArgOne does not appear to have stayed running -- perhaps it had an error?" + echo >&2 + exit 1 + fi + if "${mongo[@]}" 'admin' --eval 'quit(0)' &> /dev/null; then + # success! + break + fi + (( tries-- )) + if [ "$tries" -le 0 ]; then + echo >&2 + echo >&2 "error: $originalArgOne does not appear to have accepted connections quickly enough -- perhaps it had an error?" + echo >&2 + exit 1 + fi + sleep 1 + done + + if [ "$MONGO_INITDB_ROOT_USERNAME" ] && [ "$MONGO_INITDB_ROOT_PASSWORD" ]; then + rootAuthDatabase='admin' + + "${mongo[@]}" "$rootAuthDatabase" <<-EOJS + db.createUser({ + user: $(_js_escape "$MONGO_INITDB_ROOT_USERNAME"), + pwd: $(_js_escape "$MONGO_INITDB_ROOT_PASSWORD"), + roles: [ { role: 'root', db: $(_js_escape "$rootAuthDatabase") } ] + }) + EOJS + fi + + export MONGO_INITDB_DATABASE="${MONGO_INITDB_DATABASE:-test}" + + echo + for f in /docker-entrypoint-initdb.d/*; do + case "$f" in + *.sh) echo "$0: running $f"; . "$f" ;; + *.js) echo "$0: running $f"; "${mongo[@]}" "$MONGO_INITDB_DATABASE" "$f"; echo ;; + *) echo "$0: ignoring $f" ;; + esac + echo + done + + "${mongodHackedArgs[@]}" --shutdown + rm -f "$pidfile" + + echo + echo 'MongoDB init process complete; ready for start up.' + echo + fi + + # MongoDB 3.6+ defaults to localhost-only binding + haveBindIp= + if _mongod_hack_have_arg --bind_ip "$@" || _mongod_hack_have_arg --bind_ip_all "$@"; then + haveBindIp=1 + elif _parse_config "$@" && jq --exit-status '.net.bindIp // .net.bindIpAll' "$jsonConfigFile" > /dev/null; then + haveBindIp=1 + fi + if [ -z "$haveBindIp" ]; then + # so if no "--bind_ip" is specified, let's add "--bind_ip_all" + set -- "$@" --bind_ip_all + fi + + unset "${!MONGO_INITDB_@}" +fi + +rm -f "$jsonConfigFile" "$tempConfigFile" + +exec "$@" diff --git a/docker/mongodb-enterprise-appdb-database/licenses/LICENSE b/docker/mongodb-enterprise-appdb-database/licenses/LICENSE new file mode 100644 index 000000000..b5ca95091 --- /dev/null +++ b/docker/mongodb-enterprise-appdb-database/licenses/LICENSE @@ -0,0 +1,4 @@ +Usage of the MongoDB Enterprise Operator for Kubernetes indicates +agreement with the MongoDB Development, Test, and Evaluation Agreement + +* https://www.mongodb.com/legal/evaluation-agreement diff --git a/docker/mongodb-enterprise-database/Dockerfile.builder b/docker/mongodb-enterprise-database/Dockerfile.builder new file mode 100644 index 000000000..af4064127 --- /dev/null +++ b/docker/mongodb-enterprise-database/Dockerfile.builder @@ -0,0 +1,13 @@ +# +## Database image +# +## Contents +# +# * licenses/mongodb-enterprise-database + + +FROM scratch + + + +COPY LICENSE /data/licenses/mongodb-enterprise-database diff --git a/docker/mongodb-enterprise-database/Dockerfile.dcar b/docker/mongodb-enterprise-database/Dockerfile.dcar new file mode 100644 index 000000000..a9598703e --- /dev/null +++ b/docker/mongodb-enterprise-database/Dockerfile.dcar @@ -0,0 +1,18 @@ +{% extends "Dockerfile.ubi" %} + +{% block healthcheck %} +{%- if is_appdb %} +HEALTHCHECK --timeout=30s CMD ls /mongodb-automation/files/agent-launcher.sh || exit 1 +{%- else %} +HEALTHCHECK --timeout=30s CMD ls "${MMS_HOME}"/files/probe.sh || exit 1 +{%- endif %} +{% endblock %} + +{% block dcar_copy_scripts %} +# Copy all the required scripts from the official database image +COPY --from=official "${MMS_HOME}" ${MMS_HOME}/ +{% endblock %} + +{% block entrypoint %} +ENTRYPOINT ["/mongodb-automation/files/agent-launcher.sh"] +{% endblock %} diff --git a/docker/mongodb-enterprise-database/Dockerfile.template b/docker/mongodb-enterprise-database/Dockerfile.template new file mode 100644 index 000000000..5f4aa5abf --- /dev/null +++ b/docker/mongodb-enterprise-database/Dockerfile.template @@ -0,0 +1,58 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM {{ base_image }} + +{% block labels %} + +LABEL name="MongoDB Enterprise Database" \ + version="{{ version }}" \ + summary="MongoDB Enterprise Database Image" \ + description="MongoDB Enterprise Database Image" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +{% endblock %} + + +{% block variables %} +ENV MMS_HOME /mongodb-automation +ENV MMS_LOG_DIR /var/log/mongodb-mms-automation +{% endblock %} + +{% block packages %} +{% endblock %} + +# Set the required perms +RUN mkdir -p "${MMS_LOG_DIR}" \ + && chmod 0775 "${MMS_LOG_DIR}" \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && chmod 0775 /var/lib/mongodb-mms-automation \ + && mkdir -p /data \ + && chmod 0775 /data \ + && mkdir -p /journal \ + && chmod 0775 /journal \ + && mkdir -p "${MMS_HOME}" \ + && chmod -R 0775 "${MMS_HOME}" + +{% block dcar_copy_scripts %} +{% endblock %} + +# USER needs to be set for this image to pass RedHat verification. Some customers have these requirements as well +# It does not matter what number it is, as long as it is set to something. +# However, OpenShift will run the container as a random user, +# and the number in this configuration is not relevant. +USER 2000 + +{% block entrypoint %} +# The docker image doesn't have any scripts so by default does nothing +# The script will be copied in runtime from init containers and the operator is expected +# to override the COMMAND +ENTRYPOINT ["sleep infinity"] +{% endblock %} + +COPY --from=base /data/licenses/mongodb-enterprise-database /licenses/mongodb-enterprise-database + +{% block healthcheck %} +{% endblock %} diff --git a/docker/mongodb-enterprise-database/Dockerfile.ubi b/docker/mongodb-enterprise-database/Dockerfile.ubi new file mode 100644 index 000000000..0609561b3 --- /dev/null +++ b/docker/mongodb-enterprise-database/Dockerfile.ubi @@ -0,0 +1,40 @@ +{% extends "Dockerfile.template" %} + +{% set base_image = "registry.access.redhat.com/ubi8/ubi-minimal" %} +{% set distro = "ubi" %} + +{% block packages %} +RUN microdnf update -y && rm -rf /var/cache/yum + +# these are the packages needed for the agent +RUN microdnf install -y --disableplugin=subscription-manager \ + hostname \ + nss_wrapper \ + procps + + +# these are the packages needed for MongoDB +# (https://docs.mongodb.com/manual/tutorial/install-mongodb-enterprise-on-red-hat-tarball/ "RHEL/CentOS 8" tab) +RUN microdnf install -y --disableplugin=subscription-manager \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + jq \ + tar \ + findutils + + +{# +TODO: Find public mongodb documentation about this +# mongodb enterprise expects this library /usr/lib64/libsasl2.so.2 but +# cyrus-sasl creates it in /usr/lib64/libsasl2.so.3 instead +#} +RUN ln -s /usr/lib64/libsasl2.so.3 /usr/lib64/libsasl2.so.2 +{% endblock %} diff --git a/docker/mongodb-enterprise-database/LICENSE b/docker/mongodb-enterprise-database/LICENSE new file mode 100644 index 000000000..b5ca95091 --- /dev/null +++ b/docker/mongodb-enterprise-database/LICENSE @@ -0,0 +1,4 @@ +Usage of the MongoDB Enterprise Operator for Kubernetes indicates +agreement with the MongoDB Development, Test, and Evaluation Agreement + +* https://www.mongodb.com/legal/evaluation-agreement diff --git a/docker/mongodb-enterprise-database/README.md b/docker/mongodb-enterprise-database/README.md new file mode 100644 index 000000000..ab8547635 --- /dev/null +++ b/docker/mongodb-enterprise-database/README.md @@ -0,0 +1,44 @@ +# MongoDB Enterprise Database + +This directory hosts a Dockerfile that can be run locally for development purposes (see below) or +as part of a Kubernetes deployment, using the [MongoDB Enterprise Kubernetes Operator](../mongodb-enterprise-operator). + +### Running locally + +You can use `make clean build run` to build and run the container. + +For more details regarding the available options, run `make` or read the provided [Makefile](Makefile). + + +### Other useful commands + +**See the status of all running Automation Agents:** + +```bash +for img in $(docker ps -a -f 'ancestor=dev/mongodb-enterprise-database' | tail -n +2 | awk '{print $1}'); do echo; echo "$img"; echo "---"; docker exec -t "$img" ps -ef; echo "---"; done +``` + +**Connect to a running container:** + +```bash +docker exec -it $(docker ps -a -f 'ancestor=dev/mongodb-enterprise-database' | tail -n +2 | awk '{print $1}') /bin/bash +``` + +## RHEL based Images + +We have provided a second Dockerfile (`Dockerfile_rhel`) based on RHEL7 instead of the `jessie-slim` that the +normal image is based on. The purpose of this second image is to be uploaded to RedHat Container Catalog to be used +in OpenShift with the MongoDb ClusterServiceVersion. See the `openshift` directory in this repo. + +This image can't be built in any host, because it will require the use of a subscription service with Redhat. A RHEL +host, with subscription service enabled, is required. That's the reason behind using the Redhat build service to build +this images with. + +## Building the DCAR database image + +The dcar image needs to be built manually. + +```bash +docker build . -t 268558157000.dkr.ecr.us-east-1.amazonaws.com/dev/usaf/mongodb-enterprise-database:1.5.3 +docker push 268558157000.dkr.ecr.us-east-1.amazonaws.com/dev/usaf/mongodb-enterprise-database:1.5.3 +``` diff --git a/docker/mongodb-enterprise-init-database/Dockerfile.builder b/docker/mongodb-enterprise-init-database/Dockerfile.builder new file mode 100644 index 000000000..1868af8b7 --- /dev/null +++ b/docker/mongodb-enterprise-init-database/Dockerfile.builder @@ -0,0 +1,23 @@ +# Build compilable stuff + +ARG readiness_probe_repo +ARG readiness_probe_version +ARG version_upgrade_post_start_hook_version + +FROM ${readiness_probe_repo}:${readiness_probe_version} as readiness_builder +FROM quay.io/mongodb/mongodb-kubernetes-operator-version-upgrade-post-start-hook:${version_upgrade_post_start_hook_version} as version_upgrade_builder + +FROM scratch +ARG mongodb_tools_url_ubi + +COPY --from=readiness_builder /probes/readinessprobe /data/ +COPY --from=version_upgrade_builder /version-upgrade-hook /data/version-upgrade-hook + +ADD ${mongodb_tools_url_ubi} /data/mongodb_tools_ubi.tgz + +COPY ./docker/mongodb-enterprise-init-database/content/probe.sh /data/probe.sh + +COPY ./docker/mongodb-enterprise-init-database/content/agent-launcher-lib.sh /data/scripts/ +COPY ./docker/mongodb-enterprise-init-database/content/agent-launcher.sh /data/scripts/ + +COPY ./docker/mongodb-enterprise-init-database/content/LICENSE /data/licenses/ diff --git a/docker/mongodb-enterprise-init-database/Dockerfile.dcar b/docker/mongodb-enterprise-init-database/Dockerfile.dcar new file mode 100644 index 000000000..21df0a1a9 --- /dev/null +++ b/docker/mongodb-enterprise-init-database/Dockerfile.dcar @@ -0,0 +1,8 @@ +{% extends "Dockerfile.ubi_minimal" %} + +{% block healthcheck %} +{%- if is_appdb %} +HEALTHCHECK --timeout=30s CMD ls /probes/readinessprobe || exit 1 +{%- else %} +{%- endif %} +{% endblock %} diff --git a/docker/mongodb-enterprise-init-database/Dockerfile.template b/docker/mongodb-enterprise-init-database/Dockerfile.template new file mode 100644 index 000000000..069300dd7 --- /dev/null +++ b/docker/mongodb-enterprise-init-database/Dockerfile.template @@ -0,0 +1,42 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM {{ base_image }} + +ARG version + +{%- if is_appdb %} +LABEL name="MongoDB Enterprise Init AppDB" \ + version="mongodb-enterprise-init-appdb-${version}" \ + summary="MongoDB Enterprise AppDB Init Image" \ + description="Startup Scripts for MongoDB Enterprise Application Database for Ops Manager" \ +{%- else %} +LABEL name="MongoDB Enterprise Init Database" \ + version="mongodb-enterprise-init-database-${version}" \ + summary="MongoDB Enterprise Database Init Image" \ + description="Startup Scripts for MongoDB Enterprise Database" \ +{%- endif %} + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ + +{%- if is_appdb %} +COPY --from=base /data/version-upgrade-hook /probes/version-upgrade-hook +{%- endif %} + +{% block mongodb_tools %} +{% endblock %} + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + +{% block healthcheck %} +{% endblock %} diff --git a/docker/mongodb-enterprise-init-database/Dockerfile.ubi_minimal b/docker/mongodb-enterprise-init-database/Dockerfile.ubi_minimal new file mode 100644 index 000000000..0cbccd614 --- /dev/null +++ b/docker/mongodb-enterprise-init-database/Dockerfile.ubi_minimal @@ -0,0 +1,11 @@ +{% extends "Dockerfile.template" %} + +{% set base_image = "registry.access.redhat.com/ubi8/ubi-minimal" %} + +{% block mongodb_tools %} +RUN microdnf update --nodocs \ + && microdnf -y install --nodocs tar gzip \ + && microdnf clean all + +COPY --from=base /data/mongodb_tools_ubi.tgz /tools/mongodb_tools.tgz +{% endblock %} diff --git a/docker/mongodb-enterprise-init-database/content/LICENSE b/docker/mongodb-enterprise-init-database/content/LICENSE new file mode 100644 index 000000000..b5ca95091 --- /dev/null +++ b/docker/mongodb-enterprise-init-database/content/LICENSE @@ -0,0 +1,4 @@ +Usage of the MongoDB Enterprise Operator for Kubernetes indicates +agreement with the MongoDB Development, Test, and Evaluation Agreement + +* https://www.mongodb.com/legal/evaluation-agreement diff --git a/docker/mongodb-enterprise-init-database/content/agent-launcher-lib.sh b/docker/mongodb-enterprise-init-database/content/agent-launcher-lib.sh new file mode 100755 index 000000000..6c5ef1286 --- /dev/null +++ b/docker/mongodb-enterprise-init-database/content/agent-launcher-lib.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -Eeou pipefail + +# This is a file containing all the functions which may be needed for other shell scripts + +# see if jq is available for json logging +use_jq="$(command -v jq)" + +# log stdout as structured json with given log type +json_log() { + if [ "$use_jq" ]; then + jq --unbuffered --null-input -c --raw-input "inputs | {\"logType\": \"$1\", \"contents\": .}" + else + echo "$1" + fi +} + +# log a given message in json format +script_log() { + echo "$1" | json_log 'agent-launcher-script' +} + +# the function reacting on SIGTERM command sent by the container on its shutdown. Makes sure all processes started (including +# mongodb) receive the signal. For MongoDB this results in graceful shutdown of replication (starting from 4.0.9) which may +# take some time. The script waits for all the processes to finish, otherwise the container would terminate as Kubernetes +# waits only for the process with pid #1 to end +cleanup() { + # Important! Keep this in sync with DefaultPodTerminationPeriodSeconds constant from constants.go + termination_timeout_seconds=600 + + script_log "Caught SIGTERM signal. Passing the signal to the automation agent and the mongod processes." + + kill -15 "${agentPid:?}" + wait "${agentPid}" + + mongoPid="$(cat /data/mongod.lock)" + kill -15 "$mongoPid" + + script_log "Waiting until mongod process is shutdown. Note, that if mongod process fails to shutdown in the time specified by the 'terminationGracePeriodSeconds' property (default $termination_timeout_seconds seconds) then the container will be killed by Kubernetes." + + # dev note: we cannot use 'wait' for the external processes, seems the spinning loop is the best option + while [ -e "/proc/$mongoPid" ]; do sleep 0.1; done + + script_log "Mongod and automation agent processes are shutdown" +} + +# ensure_certs_symlinks function checks if certificates and CAs are mounted and creates symlinks to them +ensure_certs_symlinks() { + # the paths inside the pod. Move to parameters if multiple usage is needed + secrets_dir="/var/lib/mongodb-automation/secrets" + custom_ca_dir="${secrets_dir}/ca" + pod_secrets_dir="/mongodb-automation" + + if [ -d "${secrets_dir}/certs" ]; then + script_log "Found certificates in the host, will symlink to where the automation agent expects them to be" + podname=$(hostname) + + if [ ! -f "${secrets_dir}/certs/${podname}-pem" ]; then + script_log "PEM Certificate file does not exist in ${secrets_dir}/certs/${podname}-pem. Check the Secret object with certificates is well formed." + exit 1 + fi + + ln -sf "${secrets_dir}/certs/${podname}-pem" "${pod_secrets_dir}/server.pem" + fi + + if [ -d "${custom_ca_dir}" ]; then + if [ -f "${custom_ca_dir}/ca-pem" ]; then + script_log "Using CA file provided by user" + ln -sf "${custom_ca_dir}/ca-pem" "${pod_secrets_dir}/ca.pem" + else + script_log "Could not find CA file. The name of the entry on the Secret object should be 'ca-pem'" + exit 1 + fi + else + script_log "Using Kubernetes CA file" + + if [[ ! -f "${pod_secrets_dir}/ca.pem" ]]; then + ln -sf "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" "${pod_secrets_dir}/ca.pem" + fi + fi +} + +# download_agent function downloads and unpacks the Mongodb Agent +# if ${MDB_AGENT_DEBUG} is provided we will download dlv +download_agent() { + debug="${MDB_AGENT_DEBUG-}" + + pushd /tmp >/dev/null + + if [ "${debug}" = "true" ]; then + ( + cd /var/lib/mongodb-mms-automation + curl -LO https://go.dev/dl/go1.20.1.linux-amd64.tar.gz + tar -xzf go1.20.1.linux-amd64.tar.gz # TODO: make the go and dlv version configurable? + mkdir -p /var/lib/mongodb-mms-automation/gopath + export GOPATH=/var/lib/mongodb-mms-automation/gopath + export GOCACHE=/var/lib/mongodb-mms-automation/.cache + export PATH=$PATH:/var/lib/mongodb-mms-automation/go/bin + go install github.com/go-delve/delve/cmd/dlv@latest + ) + fi + + script_log "Downloading a Mongodb Agent from ${base_url:?}" + curl_opts=( + "${base_url}/download/agent/automation/mongodb-mms-automation-agent-latest.linux_x86_64.tar.gz" + "--location" "--silent" "--retry" "3" "--fail" "-v" + "--output" "automation-agent.tar.gz" + ); + + if [ "${SSL_REQUIRE_VALID_MMS_CERTIFICATES-}" = "false" ]; then + # If we are not expecting valid certs, `curl` should be run with `--insecure` option. + # The default is NOT to accept insecure connections. + curl_opts+=("--insecure") + fi + + if [ -n "${SSL_TRUSTED_MMS_SERVER_CERTIFICATE-}" ]; then + curl_opts+=("--cacert" "${SSL_TRUSTED_MMS_SERVER_CERTIFICATE}") + fi + + if ! curl "${curl_opts[@]}" &>"${MMS_LOG_DIR}/agent-launcher-script.log"; then + script_log "Error while downloading the Mongodb agent" + json_log 'agent-launcher-script' <"${MMS_LOG_DIR}/agent-launcher-script.log" + exit 1 + fi + + script_log "The Mongodb Agent binary downloaded, unpacking" + tar -xzf automation-agent.tar.gz + AGENT_VERSION=$(find . -name "mongodb-mms-automation-agent-*" | awk -F"-" '{ print $5 }') + mkdir -p "${MMS_HOME}/files" + echo "${AGENT_VERSION}" >"${MMS_HOME}/files/agent-version" + mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent "${MMS_HOME}/files/" + chmod +x "${MMS_HOME}/files/mongodb-mms-automation-agent" + rm -rf automation-agent.tar.gz mongodb-mms-automation-agent-*.linux_x86_64 + script_log "The Automation Agent was deployed at ${MMS_HOME}/files/mongodb-mms-automation-agent" + + + popd >/dev/null +} diff --git a/docker/mongodb-enterprise-init-database/content/agent-launcher.sh b/docker/mongodb-enterprise-init-database/content/agent-launcher.sh new file mode 100755 index 000000000..b1ccd3b7c --- /dev/null +++ b/docker/mongodb-enterprise-init-database/content/agent-launcher.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +set -Eeou pipefail + +# shellcheck disable=SC1091 +source /opt/scripts/agent-launcher-lib.sh + +# This is the directory corresponding to 'options.downloadBase' in the automation config - the directory where +# the agent will extract MongoDB binaries to +mdb_downloads_dir="/var/lib/mongodb-mms-automation" + +# The path to the automation config file in case the agent is run in headless mode +cluster_config_file="/var/lib/mongodb-automation/cluster-config.json" + +# Always copy the tools provided by the init container to the directory where the agent looks for all binaries +cp -r /opt/scripts/tools/* "${mdb_downloads_dir}" + +# file required by Automation Agents of authentication is enabled. +touch "${mdb_downloads_dir}/keyfile" +chmod 600 "${mdb_downloads_dir}/keyfile" + +ensure_certs_symlinks + +# Ensure that the user has an entry in /etc/passwd +current_uid=$(id -u) +declare -r current_uid +if ! grep -q "${current_uid}" /etc/passwd ; then + # Adding it here to avoid panics in the automation agent + sed -e "s/^mongodb:/builder:/" /etc/passwd > /tmp/passwd + echo "mongodb:x:$(id -u):$(id -g):,,,:/mongodb-automation:/bin/bash" >> /tmp/passwd + export LD_PRELOAD=libnss_wrapper.so + export NSS_WRAPPER_PASSWD=/tmp/passwd + export NSS_WRAPPER_GROUP=/etc/group +fi + +# Create a symlink, after the volumes have been mounted +# If the journal directory already exists (this could be the migration of the existing MongoDB database) - we need +# to copy it to the correct location first and remove a directory +if [[ -d /data/journal ]] && [[ ! -L /data/journal ]]; then + script_log "The journal directory /data/journal already exists - moving its content to /journal" + if [[ $(find /data/journal -maxdepth 1 | wc -l) -gt 0 ]]; then + mv /data/journal/* /journal + fi + rm -rf /data/journal +fi + +ln -sf /journal /data/ +script_log "Created symlink: /data/journal -> $(readlink -f /data/journal)" + +# If it is a migration of the existing MongoDB - then there could be a mongodb.log in a default location - +# let's try to copy it to a new directory +if [[ -f /data/mongodb.log ]] && [[ ! -f "${MMS_LOG_DIR}/mongodb.log" ]]; then + script_log "The mongodb log file /data/mongodb.log already exists - moving it to ${MMS_LOG_DIR}" + mv /data/mongodb.log "${MMS_LOG_DIR}" +fi + +base_url="${BASE_URL-}" # If unassigned, set to empty string to avoid set-u errors +base_url="${base_url%/}" # Remove any accidentally defined trailing slashes +declare -r base_url + +# Download the Automation Agent from Ops Manager +# Note, that it will be skipped if the agent is supposed to be run in headless mode +if [[ -n "${base_url}" ]]; then + download_agent +fi + +# Start the Automation Agent +agentOpts=( + "-mmsGroupId" "${GROUP_ID-}" + "-pidfilepath" "${MMS_HOME}/mongodb-mms-automation-agent.pid" + "-maxLogFileDurationHrs" "24" + "-logLevel" "${LOG_LEVEL:-INFO}" +) +AGENT_VERSION="$(cat "${MMS_HOME}"/files/agent-version)" +script_log "Automation Agent version: ${AGENT_VERSION}" + +# in multi-cluster mode we need to override the hostname with which, agents +# registers itself, use service FQDN instead of POD FQDN, this mapping is mounted into +# the pod using configmap +hostpath="" +if [ "${MULTI_CLUSTER_MODE-}" = "true" ]; then + hostpath="$(hostname -f)" +else + hostpath="$(hostname)" +fi + +# We apply the ephemeralPortOffset when using externalDomain in Single Cluster +# or whenever Multi-Cluster is on. +override_file="/opt/scripts/config/${hostpath}" +if [[ -f "${override_file}" ]]; then + override="$(cat "$override_file")" + agentOpts+=("-overrideLocalHost" "${override}") + agentOpts+=("-ephemeralPortOffset" "1") +elif [ "${MULTI_CLUSTER_MODE-}" = "true" ]; then + agentOpts+=("-ephemeralPortOffset" "1") +fi + +agentOpts+=("-healthCheckFilePath" "${MMS_LOG_DIR}/agent-health-status.json") +agentOpts+=("-useLocalMongoDbTools") + +if [[ -n "${base_url}" ]]; then + agentOpts+=("-mmsBaseUrl" "${base_url}") +else + agentOpts+=("-cluster" "${cluster_config_file}") + # we need to open the web server on localhost even though we don't use it - otherwise Agent doesn't + # produce status information at all (we need it in health file) + agentOpts+=("-serveStatusPort" "5000") + script_log "Mongodb Agent is configured to run in \"headless\" mode using local config file" +fi + +if [[ -n "${HTTP_PROXY-}" ]]; then + agentOpts+=("-httpProxy" "${HTTP_PROXY}") +fi + +if [[ -n "${SSL_TRUSTED_MMS_SERVER_CERTIFICATE-}" ]]; then + agentOpts+=("-sslTrustedMMSServerCertificate" "${SSL_TRUSTED_MMS_SERVER_CERTIFICATE}") +fi + +if [[ "${SSL_REQUIRE_VALID_MMS_CERTIFICATES-}" != "false" ]]; then + # Only set this option when valid certs are required. The default is false + agentOpts+=("-sslRequireValidMMSServerCertificates") +fi + +# we can't directly use readarray as this bash version doesn't support +# the delimiter option +splittedAgentFlags=(); +while read -rd,; do + splittedAgentFlags+=("$REPLY") +done <<<"$AGENT_FLAGS"; + +AGENT_API_KEY="$(cat "${MMS_HOME}"/agent-api-key/agentApiKey)" +script_log "Launching automation agent with following arguments: ${agentOpts[*]} -mmsApiKey ${AGENT_API_KEY+} ${splittedAgentFlags[*]}" + +agentOpts+=("-mmsApiKey" "${AGENT_API_KEY-}") + +rm /tmp/mongodb-mms-automation-cluster-backup.json || true + +debug="${MDB_AGENT_DEBUG-}" +if [ "${debug}" = "true" ]; then + export PATH=$PATH:/var/lib/mongodb-mms-automation/gopath/bin + dlv --headless=true --listen=:5006 --accept-multiclient=true --continue --api-version=2 exec "${MMS_HOME}/files/mongodb-mms-automation-agent" -- "${agentOpts[@]}" "${splittedAgentFlags[@]}" 2>> "${MMS_LOG_DIR}/automation-agent-stderr.log" > >(json_log "automation-agent-stdout") & +else +# Note, that we do logging in subshell - this allows us to save the correct PID to variable (not the logging one) + "${MMS_HOME}/files/mongodb-mms-automation-agent" "${agentOpts[@]}" "${splittedAgentFlags[@]}" 2>> "${MMS_LOG_DIR}/automation-agent-stderr.log" > >(json_log "automation-agent-stdout") & +fi +export agentPid=$! + +trap cleanup SIGTERM + +# Note that we don't care about orphan processes as they will die together with container in case of any troubles +# tail's -F flag is equivalent to --follow=name --retry. Should we track log rotation events? +AGENT_VERBOSE_LOG="${MMS_LOG_DIR}/automation-agent-verbose.log" && touch "${AGENT_VERBOSE_LOG}" +AGENT_STDERR_LOG="${MMS_LOG_DIR}/automation-agent-stderr.log" && touch "${AGENT_STDERR_LOG}" +MONGODB_LOG="${MMS_LOG_DIR}/mongodb.log" && touch "${MONGODB_LOG}" + +tail -F "${AGENT_VERBOSE_LOG}" 2> /dev/null | json_log 'automation-agent-verbose' & +tail -F "${AGENT_STDERR_LOG}" 2> /dev/null | json_log 'automation-agent-stderr' & +tail -F "${MONGODB_LOG}" 2> /dev/null | json_log 'mongodb' & + +wait diff --git a/docker/mongodb-enterprise-init-database/content/probe.sh b/docker/mongodb-enterprise-init-database/content/probe.sh new file mode 100755 index 000000000..3f81759ed --- /dev/null +++ b/docker/mongodb-enterprise-init-database/content/probe.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -Eeou pipefail + +check_agent_alive() { + pgrep --exact 'mongodb-mms-aut' +} + +check_mongod_alive() { + pgrep --exact 'mongod' +} + +check_mongos_alive() { + pgrep --exact 'mongos' +} + +check_mongo_process_alive() { + # the mongod process pid might not always exist + # 1. when the container is being created the mongod package needs to be + # downloaded. the agent will wait for 1 hour before giving up. + # 2. the mongod process might be getting updated, we'll set a + # failureThreshold on the livenessProbe to a few minutes before we + # give up. + + check_mongod_alive || check_mongos_alive +} + +# One of 2 conditions is sufficient to state that a Pod is "Alive": +# +# 1. There is an agent process running +# 2. There is a `mongod` or `mongos` process running +# +check_agent_alive || check_mongo_process_alive diff --git a/docker/mongodb-enterprise-init-ops-manager/Dockerfile.builder b/docker/mongodb-enterprise-init-ops-manager/Dockerfile.builder new file mode 100644 index 000000000..bc40de4cb --- /dev/null +++ b/docker/mongodb-enterprise-init-ops-manager/Dockerfile.builder @@ -0,0 +1,14 @@ +# +# Dockerfile for Init Ops Manager Context. +# + +FROM golang:1.20 as builder +WORKDIR /go/src +ADD . . +RUN CGO_ENABLED=0 go build -a -buildvcs=false -o /data/scripts/mmsconfiguration ./mmsconfiguration +RUN CGO_ENABLED=0 go build -a -buildvcs=false -o /data/scripts/backup-daemon-readiness-probe ./backupdaemon_readinessprobe/ + +COPY scripts/docker-entry-point.sh /data/scripts/ +COPY scripts/backup-daemon-liveness-probe.sh /data/scripts/ + +COPY LICENSE /data/licenses/mongodb-enterprise-ops-manager diff --git a/docker/mongodb-enterprise-init-ops-manager/Dockerfile.dcar b/docker/mongodb-enterprise-init-ops-manager/Dockerfile.dcar new file mode 100644 index 000000000..c2a8e29d2 --- /dev/null +++ b/docker/mongodb-enterprise-init-ops-manager/Dockerfile.dcar @@ -0,0 +1,5 @@ +{% extends "Dockerfile.ubi_minimal" %} + +{% block healthcheck %} +HEALTHCHECK --timeout=30s CMD ls /scripts/docker-entry-point.sh || exit 1 +{% endblock %} diff --git a/docker/mongodb-enterprise-init-ops-manager/Dockerfile.template b/docker/mongodb-enterprise-init-ops-manager/Dockerfile.template new file mode 100644 index 000000000..30c178fac --- /dev/null +++ b/docker/mongodb-enterprise-init-ops-manager/Dockerfile.template @@ -0,0 +1,25 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM {{ base_image }} + +LABEL name="MongoDB Enterprise Ops Manager Init" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="mongodb-enterprise-init-ops-manager-{{version}}" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Init Image" \ + description="Startup Scripts for MongoDB Enterprise Ops Manager" + + +COPY --from=base /data/scripts /scripts +COPY --from=base /data/licenses /licenses + +{% block packages %} +{% endblock %} + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "/scripts/docker-entry-point.sh", "/scripts/backup-daemon-liveness-probe.sh", "/scripts/mmsconfiguration", "/scripts/backup-daemon-readiness-probe", "/opt/scripts/" ] + +{% block healthcheck %} +{% endblock %} diff --git a/docker/mongodb-enterprise-init-ops-manager/Dockerfile.ubi_minimal b/docker/mongodb-enterprise-init-ops-manager/Dockerfile.ubi_minimal new file mode 100644 index 000000000..9cec3357d --- /dev/null +++ b/docker/mongodb-enterprise-init-ops-manager/Dockerfile.ubi_minimal @@ -0,0 +1,8 @@ +{% extends "Dockerfile.template" %} + +{% set base_image = "registry.access.redhat.com/ubi8/ubi-minimal" %} + +{% block packages %} +RUN microdnf update --nodocs \ + && microdnf clean all +{% endblock %} diff --git a/docker/mongodb-enterprise-init-ops-manager/LICENSE b/docker/mongodb-enterprise-init-ops-manager/LICENSE new file mode 100644 index 000000000..b5ca95091 --- /dev/null +++ b/docker/mongodb-enterprise-init-ops-manager/LICENSE @@ -0,0 +1,4 @@ +Usage of the MongoDB Enterprise Operator for Kubernetes indicates +agreement with the MongoDB Development, Test, and Evaluation Agreement + +* https://www.mongodb.com/legal/evaluation-agreement diff --git a/docker/mongodb-enterprise-init-ops-manager/backupdaemon_readinessprobe/backupdaemon_readiness.go b/docker/mongodb-enterprise-init-ops-manager/backupdaemon_readinessprobe/backupdaemon_readiness.go new file mode 100644 index 000000000..b1480fec7 --- /dev/null +++ b/docker/mongodb-enterprise-init-ops-manager/backupdaemon_readinessprobe/backupdaemon_readiness.go @@ -0,0 +1,79 @@ +package main + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "os" + "time" + + "golang.org/x/xerrors" +) + +const ( + healthEndpointPortEnv = "HEALTH_ENDPOINT_PORT" +) + +// HealthResponse represents the response given from the backup daemon health endpoint. +// Sample responses: +// - {"sync_db":"OK","backup_db":"OK","mms_db":"OK"} +// - {"backup_db":"no master","mms_db":"no master"} +type HealthResponse struct { + SyncDb string `json:"sync_db"` + BackupDb string `json:"backup_db"` + MmsDb string `json:"mms_db"` +} + +func main() { + os.Exit(checkHealthEndpoint(&http.Client{ + Timeout: 5 * time.Second, + })) +} + +// checkHealthEndpoint checks the BackupDaemon health endpoint +// and ensures that the AppDB is up and running. +func checkHealthEndpoint(getter httpGetter) int { + hr, err := getHealthResponse(getter) + if err != nil { + fmt.Printf("error getting health response: %s\n", err) + return 1 + } + + fmt.Printf("received response: %+v\n", hr) + if hr.MmsDb == "OK" { + return 0 + } + return 1 +} + +type httpGetter interface { + Get(url string) (*http.Response, error) +} + +// getHealthResponse fetches the health response from the health endpoint. +func getHealthResponse(getter httpGetter) (HealthResponse, error) { + url := fmt.Sprintf("http://localhost:%s/health", os.Getenv(healthEndpointPortEnv)) + fmt.Printf("attempting GET request to: [%s]\n", url) + resp, err := getter.Get(url) + + if err != nil { + return HealthResponse{}, xerrors.Errorf("failed to reach health endpoint: %w", err) + } + + if resp.StatusCode != 200 { + return HealthResponse{}, xerrors.Errorf("received status code [%d] but expected [200]", resp.StatusCode) + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return HealthResponse{}, xerrors.Errorf("failed to read response body: %w", err) + } + + hr := HealthResponse{} + if err := json.Unmarshal(body, &hr); err != nil { + return HealthResponse{}, xerrors.Errorf("failed to unmarshal response: %w", err) + } + + return hr, nil +} diff --git a/docker/mongodb-enterprise-init-ops-manager/backupdaemon_readinessprobe/backupdaemon_readiness_test.go b/docker/mongodb-enterprise-init-ops-manager/backupdaemon_readinessprobe/backupdaemon_readiness_test.go new file mode 100644 index 000000000..e7e93e693 --- /dev/null +++ b/docker/mongodb-enterprise-init-ops-manager/backupdaemon_readinessprobe/backupdaemon_readiness_test.go @@ -0,0 +1,78 @@ +package main + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "golang.org/x/xerrors" +) + +type mockHttpGetter struct { + resp *http.Response + err error +} + +func (m *mockHttpGetter) Get(string) (*http.Response, error) { + return m.resp, m.err +} + +// newMockHttpGetter returns a httpGetter which will return the given status code, the body and error provided. +func newMockHttpGetter(code int, body []byte, err error) *mockHttpGetter { + return &mockHttpGetter{ + resp: &http.Response{ + StatusCode: code, + Body: ioutil.NopCloser(bytes.NewReader(body)), + }, + err: err, + } +} + +func healthStatusResponseToBytes(hr HealthResponse) []byte { + bytes, err := json.Marshal(hr) + if err != nil { + panic(err) + } + return bytes +} + +func TestCheckHealthEndpoint(t *testing.T) { + t.Run("Test 200 Code with invalid body fails", func(t *testing.T) { + m := newMockHttpGetter(200, healthStatusResponseToBytes(HealthResponse{ + SyncDb: "OK", + BackupDb: "OK", + MmsDb: "no master", + }), nil) + code := checkHealthEndpoint(m) + assert.Equal(t, 1, code) + }) + + t.Run("Test 200 Code with valid body succeeds", func(t *testing.T) { + m := newMockHttpGetter(200, healthStatusResponseToBytes(HealthResponse{ + SyncDb: "OK", + BackupDb: "OK", + MmsDb: "OK", + }), nil) + code := checkHealthEndpoint(m) + assert.Equal(t, 0, code) + }) + + t.Run("Test non-200 Code fails", func(t *testing.T) { + m := newMockHttpGetter(300, healthStatusResponseToBytes(HealthResponse{ + SyncDb: "OK", + BackupDb: "OK", + MmsDb: "OK", + }), nil) + code := checkHealthEndpoint(m) + assert.Equal(t, 1, code) + }) + + t.Run("Test error from http client fails", func(t *testing.T) { + m := newMockHttpGetter(200, nil, xerrors.Errorf("error")) + code := checkHealthEndpoint(m) + assert.Equal(t, 1, code) + }) +} diff --git a/docker/mongodb-enterprise-init-ops-manager/go.mod b/docker/mongodb-enterprise-init-ops-manager/go.mod new file mode 100644 index 000000000..0553e1619 --- /dev/null +++ b/docker/mongodb-enterprise-init-ops-manager/go.mod @@ -0,0 +1,14 @@ +module github.com/10gen/ops-manager-kubernetes/docker/mongodb-enterprise-init-ops-manager + +go 1.20 + +require ( + github.com/stretchr/testify v1.7.0 + golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 +) + +require ( + github.com/davecgh/go-spew v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect +) diff --git a/docker/mongodb-enterprise-init-ops-manager/go.sum b/docker/mongodb-enterprise-init-ops-manager/go.sum new file mode 100644 index 000000000..92e9a2a05 --- /dev/null +++ b/docker/mongodb-enterprise-init-ops-manager/go.sum @@ -0,0 +1,13 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/docker/mongodb-enterprise-init-ops-manager/mmsconfiguration/edit_mms_configuration.go b/docker/mongodb-enterprise-init-ops-manager/mmsconfiguration/edit_mms_configuration.go new file mode 100755 index 000000000..6be87d4eb --- /dev/null +++ b/docker/mongodb-enterprise-init-ops-manager/mmsconfiguration/edit_mms_configuration.go @@ -0,0 +1,261 @@ +package main + +import ( + "errors" + "fmt" + "io/ioutil" + "net" + "os" + "strings" + + "golang.org/x/xerrors" +) + +const ( + mmsJvmParamsVar = "JAVA_MMS_UI_OPTS" + backupDaemonJvmParamsVar = "JAVA_DAEMON_OPTS" + omPropertyPrefix = "OM_PROP_" + lineBreak = "\n" + commentPrefix = "#" + propOverwriteFmt = "%s=\"${%s} %s\"" + backupDaemon = "BACKUP_DAEMON" + // keep in sync with AppDBConnectionStringPath constant from "github.com/10gen/ops-manager-kubernetes/controllers/operator/construct" package. + // currently we cannot reference code from outside of docker/mongodb-enterprise-init-ops-manager + // because this folder is set as the docker build context (configured in inventories/init_om.yaml) + appDbConnectionStringPath = "/mongodb-ops-manager/.mongodb-mms-connection-string" + appDbConnectionStringFilePath = appDbConnectionStringPath + "/connectionString" + // keep in sync with MmsMongoUri constant from github.com/10gen/ops-manager-kubernetes/pkg/util + appDbUriKey = "mongo.mongoUri" +) + +func updateConfFile(confFile string) error { + confFilePropertyName := mmsJvmParamsVar + var isBackupDaemon bool + if _, isBackupDaemon = os.LookupEnv(backupDaemon); isBackupDaemon { + confFilePropertyName = backupDaemonJvmParamsVar + } + + customJvmParamsVar := "CUSTOM_" + confFilePropertyName + jvmParams, jvmParamsEnvVarExists := os.LookupEnv(customJvmParamsVar) + + if !jvmParamsEnvVarExists || jvmParams == "" { + fmt.Printf("%s not specified, not modifying %s\n", customJvmParamsVar, confFile) + return nil + } + + if isBackupDaemon { + fqdn, err := getHostnameFQDN() + if err == nil { + // We need to add hostname to the Backup daemon + jvmParams += " -Dmms.system.hostname=" + fqdn + } + } + + newMmsJvmParams := fmt.Sprintf(propOverwriteFmt, confFilePropertyName, confFilePropertyName, jvmParams) + fmt.Printf("Appending %s to %s\n", newMmsJvmParams, confFile) + + return appendLinesToFile(confFile, getJvmParamDocString()+newMmsJvmParams+lineBreak) +} + +// getHostnameFQDN returns the FQDN name for this Pod, this is, the Pod's hostname +// and complete Domain. +// +// We use `LookupAddr` on each IP in the host, and calculate which one _is the FQDN_ by +// a simple heuristic: +// +// - the longest string with _dots_ in it should be the FQDN. +func getHostnameFQDN() (string, error) { + ipList, err := getIPv4Addresses() + if err != nil { + return "", err + } + + longestFQDN := "" + for _, ip := range ipList { + fqdnList, err := net.LookupAddr(ip.String()) + if err != nil { + // Host could not be found, skip this IP. + continue + } + + for _, fqdn := range fqdnList { + + // Only consider fqdns with '.' on it + if !strings.Contains(fqdn, ".") { + continue + } + if len(fqdn) > len(longestFQDN) { + longestFQDN = fqdn + } + } + } + + if longestFQDN == "" { + return "", errors.New("Could not find FQDN for this host") + } + + // Remove the trailing . if in there + return strings.TrimRight(longestFQDN, "."), nil +} + +// getIPv4Addresses returns a list of IP addresses in this machine. +// +// The IP addresses are obtained by iterating through the network interfaces +// found in the host. +func getIPv4Addresses() ([]net.IP, error) { + ipList := []net.IP{} + + ifaces, err := net.Interfaces() + if err != nil { + return []net.IP{}, err + } + for _, i := range ifaces { + addrs, err := i.Addrs() + if err != nil { + return []net.IP{}, err + } + for _, addr := range addrs { + var ip net.IP + switch v := addr.(type) { + case *net.IPNet: + ip = v.IP + case *net.IPAddr: + ip = v.IP + } + if ip.IsLoopback() { + continue + } + + ipList = append(ipList, ip) + } + } + + return ipList, nil +} + +func getMmsProperties() (map[string]string, error) { + newProperties := getOmPropertiesFromEnvVars() + + appDbConnectionString, err := ioutil.ReadFile(appDbConnectionStringFilePath) + if err != nil { + return nil, err + } + newProperties[appDbUriKey] = string(appDbConnectionString) + // Enable dualConnectors to allow the kubelet to perform health checks through HTTP + newProperties["mms.https.dualConnectors"] = "true" + + return newProperties, nil +} + +func updatePropertiesFile(propertiesFile string, newProperties map[string]string) error { + lines, err := readLinesFromFile(propertiesFile) + if err != nil { + return err + } + + lines = updateMmsProperties(lines, newProperties) + fmt.Printf("Updating configuration properties file %s\n", propertiesFile) + err = writeLinesToFile(propertiesFile, lines) + return err +} + +func readLinesFromFile(name string) ([]string, error) { + input, err := ioutil.ReadFile(name) + if err != nil { + return nil, xerrors.Errorf("error reading file %s: %w", name, err) + } + return strings.Split(string(input), lineBreak), nil +} + +func writeLinesToFile(name string, lines []string) error { + output := strings.Join(lines, lineBreak) + + err := ioutil.WriteFile(name, []byte(output), 0775) + if err != nil { + return xerrors.Errorf("error writing to file %s: %w", name, err) + } + return nil +} + +func appendLinesToFile(name string, lines string) error { + f, err := os.OpenFile(name, os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return xerrors.Errorf("error opening file %s: %w", name, err) + } + + if _, err = f.WriteString(lines); err != nil { + return xerrors.Errorf("error writing to file %s: %w", name, err) + } + + err = f.Close() + return err + +} + +func getOmPropertiesFromEnvVars() map[string]string { + props := map[string]string{} + for _, pair := range os.Environ() { + if !strings.HasPrefix(pair, omPropertyPrefix) { + continue + } + + p := strings.SplitN(pair, "=", 2) + key := strings.Replace(p[0], omPropertyPrefix, "", 1) + key = strings.ReplaceAll(key, "_", ".") + props[key] = p[1] + } + return props +} + +func updateMmsProperties(lines []string, newProperties map[string]string) []string { + seenProperties := map[string]bool{} + + // Overwrite existing properties + for i, line := range lines { + if strings.HasPrefix(line, commentPrefix) || !strings.Contains(line, "=") { + continue + } + + key := strings.Split(line, "=")[0] + if newVal, ok := newProperties[key]; ok { + lines[i] = fmt.Sprintf("%s=%s", key, newVal) + seenProperties[key] = true + } + } + + // Add new properties + for key, val := range newProperties { + if _, ok := seenProperties[key]; !ok { + lines = append(lines, fmt.Sprintf("%s=%s", key, val)) + } + } + return lines +} + +func getJvmParamDocString() string { + commentMarker := strings.Repeat("#", 55) + return fmt.Sprintf("%s\n## This is the custom JVM configuration set by the Operator\n%s\n\n", commentMarker, commentMarker) +} + +func main() { + if len(os.Args) < 3 { + fmt.Printf("Incorrect arguments %s, must specify path to conf file and path to properties file"+lineBreak, os.Args[1:]) + os.Exit(1) + } + confFile := os.Args[1] + propertiesFile := os.Args[2] + if err := updateConfFile(confFile); err != nil { + fmt.Println(err) + os.Exit(1) + } + + newProperties, err := getMmsProperties() + if err != nil { + fmt.Println(err) + os.Exit(1) + } + if err := updatePropertiesFile(propertiesFile, newProperties); err != nil { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/docker/mongodb-enterprise-init-ops-manager/mmsconfiguration/edit_mms_configuration_test.go b/docker/mongodb-enterprise-init-ops-manager/mmsconfiguration/edit_mms_configuration_test.go new file mode 100755 index 000000000..63fa1b058 --- /dev/null +++ b/docker/mongodb-enterprise-init-ops-manager/mmsconfiguration/edit_mms_configuration_test.go @@ -0,0 +1,78 @@ +package main + +import ( + "fmt" + "io/ioutil" + "math/rand" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestEditMmsConfiguration_UpdateConfFile_Mms(t *testing.T) { + confFile := _createTestConfFile() + t.Setenv("CUSTOM_JAVA_MMS_UI_OPTS", "-Xmx4000m -Xms4000m") + err := updateConfFile(confFile) + assert.NoError(t, err) + updatedContent := _readLinesFromFile(confFile) + assert.Equal(t, updatedContent[7], "JAVA_MMS_UI_OPTS=\"${JAVA_MMS_UI_OPTS} -Xmx4000m -Xms4000m\"") +} + +func TestEditMmsConfiguration_UpdateConfFile_BackupDaemon(t *testing.T) { + confFile := _createTestConfFile() + t.Setenv("BACKUP_DAEMON", "something") + t.Setenv("CUSTOM_JAVA_DAEMON_OPTS", "-Xmx4000m -Xms4000m") + err := updateConfFile(confFile) + assert.NoError(t, err) +} + +func TestEditMmsConfiguration_GetOmPropertiesFromEnvVars(t *testing.T) { + val := fmt.Sprintf("test%d", rand.Intn(1000)) + key := "OM_PROP_test_edit_mms_configuration_get_om_props" + t.Setenv(key, val) + props := getOmPropertiesFromEnvVars() + assert.Equal(t, props["test.edit.mms.configuration.get.om.props"], val) +} + +func TestEditMmsConfiguration_UpdatePropertiesFile(t *testing.T) { + newProperties := map[string]string{ + "mms.test.prop": "somethingNew", + "mms.test.prop.new": "400"} + propFile := _createTestPropertiesFile() + err := updatePropertiesFile(propFile, newProperties) + assert.NoError(t, err) + + updatedContent := _readLinesFromFile(propFile) + assert.Equal(t, updatedContent[0], "mms.prop=1234") + assert.Equal(t, updatedContent[1], "mms.test.prop5=") + assert.Equal(t, updatedContent[2], "mms.test.prop=somethingNew") + assert.Equal(t, updatedContent[3], "mms.test.prop.new=400") +} + +func _createTestConfFile() string { + contents := "JAVA_MMS_UI_OPTS=\"${JAVA_MMS_UI_OPTS} -Xmx4352m -Xss328k -Xms4352m -XX:NewSize=600m -Xmn1500m -XX:ReservedCodeCacheSize=128m -XX:-OmitStackTraceInFastThrow\"\n" + contents += "JAVA_DAEMON_OPTS= \"${JAVA_DAEMON_OPTS} -DMONGO.BIN.PREFIX=\"\n\n" + return _writeTempFileWithContent(contents, "conf") +} + +func _createTestPropertiesFile() string { + contents := "mms.prop=1234\nmms.test.prop5=\nmms.test.prop=something" + return _writeTempFileWithContent(contents, "prop") +} + +func _readLinesFromFile(name string) []string { + content, _ := ioutil.ReadFile(name) + return strings.Split(string(content), "\n") +} + +func _writeTempFileWithContent(content string, prefix string) string { + tmpfile, _ := ioutil.TempFile("", prefix) + + _, _ = tmpfile.WriteString(content) + + _ = tmpfile.Close() + + return tmpfile.Name() + +} diff --git a/docker/mongodb-enterprise-init-ops-manager/scripts/backup-daemon-liveness-probe.sh b/docker/mongodb-enterprise-init-ops-manager/scripts/backup-daemon-liveness-probe.sh new file mode 100755 index 000000000..ea8daa188 --- /dev/null +++ b/docker/mongodb-enterprise-init-ops-manager/scripts/backup-daemon-liveness-probe.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -Eeou pipefail + +check_backup_daemon_alive () { + pgrep --exact 'mms-app' || pgrep -f '/mongodb-ops-manager/jdk/bin/mms-app' +} + +check_backup_daemon_alive diff --git a/docker/mongodb-enterprise-init-ops-manager/scripts/docker-entry-point.sh b/docker/mongodb-enterprise-init-ops-manager/scripts/docker-entry-point.sh new file mode 100755 index 000000000..e4e46189b --- /dev/null +++ b/docker/mongodb-enterprise-init-ops-manager/scripts/docker-entry-point.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash + +set -Eeou pipefail + +# the function reacting on SIGTERM command sent by the container on its shutdown. Redirects the signal +# to the child process ("tail" in this case) +cleanup () { + echo "Caught SIGTERM signal." + kill -TERM "$child" +} + +# we need to change the Home directory for current bash so that the gen key was found correctly +# (the key is searched in "${HOME}/.mongodb-mms/gen.key") +HOME=${MMS_HOME} +CONFIG_TEMPLATE_DIR=${MMS_HOME}/conf-template +CONFIG_DIR=${MMS_HOME}/conf + +if [ -d "${CONFIG_TEMPLATE_DIR}" ] +then + if [ "$(ls -A $CONFIG_DIR)" ]; then + echo "The ${CONFIG_DIR} directory is not empty. Skipping copying files from ${CONFIG_TEMPLATE_DIR}" + echo "This might cause errors when booting up the OpsManager with read-only root filesystem" + else + echo "Copying ${CONFIG_TEMPLATE_DIR} content to ${CONFIG_DIR}" + cp "${CONFIG_TEMPLATE_DIR}"/* "${CONFIG_DIR}" + echo "Done copying ${CONFIG_TEMPLATE_DIR} content to ${CONFIG_DIR}" + fi +else + echo "It seems you're running an older version of the Ops Manager image." + echo "Please pull the latest one." +fi + +# Execute script that updates properties and conf file used to start ops manager +echo "Updating configuration properties file ${MMS_PROP_FILE} and conf file ${MMS_CONF_FILE}" +/opt/scripts/mmsconfiguration "${MMS_CONF_FILE}" "${MMS_PROP_FILE}" + +if [[ -z ${BACKUP_DAEMON+x} ]]; then + echo "Starting Ops Manager" + "${MMS_HOME}/bin/mongodb-mms" start_mms || { + echo "Startup of Ops Manager failed with code $?" + if [[ -f ${MMS_LOG_DIR}/mms0-startup.log ]]; then + echo + echo "mms0-startup.log:" + echo + cat "${MMS_LOG_DIR}/mms0-startup.log" + fi + if [[ -f ${MMS_LOG_DIR}/mms0.log ]]; then + echo + echo "mms0.log:" + echo + cat "${MMS_LOG_DIR}/mms0.log" + fi + if [[ -f ${MMS_LOG_DIR}/mms-migration.log ]]; then + echo + echo "mms-migration.log" + echo + cat "${MMS_LOG_DIR}/mms-migration.log" + fi + exit 1 + } + + trap cleanup SIGTERM + tail -F -n 1000 "${MMS_LOG_DIR}/mms0.log" "${MMS_LOG_DIR}/mms0-startup.log" "${MMS_LOG_DIR}/mms-migration.log" & +else + echo "Starting Ops Manager Backup Daemon" + "${MMS_HOME}/bin/mongodb-mms" start_backup_daemon + trap cleanup SIGTERM + + tail -F "${MMS_LOG_DIR}/daemon.log" & +fi + +child=$! +wait "$child" diff --git a/docker/mongodb-enterprise-operator/Dockerfile.builder b/docker/mongodb-enterprise-operator/Dockerfile.builder new file mode 100644 index 000000000..30289964c --- /dev/null +++ b/docker/mongodb-enterprise-operator/Dockerfile.builder @@ -0,0 +1,41 @@ +# +# Dockerfile for Operator. +# to be called from git root +# docker build . -f docker/mongodb-enterprise-operator/Dockerfile.builder +# + +FROM golang:1.20 as builder + +ARG release_version +ARG log_automation_config_diff + + +COPY go.sum go.mod /go/src/github.com/10gen/ops-manager-kubernetes/ +WORKDIR /go/src/github.com/10gen/ops-manager-kubernetes +RUN go mod download + +COPY . /go/src/github.com/10gen/ops-manager-kubernetes + +RUN go version +RUN git version +RUN mkdir /build && go build -o /build/mongodb-enterprise-operator \ + -buildvcs=false \ + -ldflags="-s -w -X github.com/10gen/ops-manager-kubernetes/pkg/util.OperatorVersion=${release_version} \ + -X github.com/10gen/ops-manager-kubernetes/pkg/util.LogAutomationConfigDiff=${log_automation_config_diff}" + +ADD https://github.com/stedolan/jq/releases/download/jq-1.6/jq-linux64 /usr/local/bin/jq +RUN chmod +x /usr/local/bin/jq + +RUN mkdir -p /data +RUN cat release.json | jq -r '.supportedImages."mongodb-agent".opsManagerMapping' > /data/om_version_mapping.json +RUN chmod +r /data/om_version_mapping.json + +RUN go install github.com/go-delve/delve/cmd/dlv@latest + +FROM scratch + +COPY --from=builder /go/bin/dlv /data/dlv +COPY --from=builder /build/mongodb-enterprise-operator /data/ +COPY --from=builder /data/om_version_mapping.json /data/om_version_mapping.json + +ADD docker/mongodb-enterprise-operator/licenses /data/licenses/ diff --git a/docker/mongodb-enterprise-operator/Dockerfile.dcar b/docker/mongodb-enterprise-operator/Dockerfile.dcar new file mode 100644 index 000000000..796747b3b --- /dev/null +++ b/docker/mongodb-enterprise-operator/Dockerfile.dcar @@ -0,0 +1,18 @@ +{% extends "Dockerfile.ubi" %} + +# In DCAR enviornment, the operator will be copied from the internal nexus server. +{% block external_packages %} + # dcar will build remotely from inside customer disconnected environment + # TODO - consider using S3 as local_repo instead of nexus for testing + WORKDIR /opt + RUN curl -fksSL https://{{ local_repo }}/mongodb/mongodb-enterprise/mongodb-enterprise-operator/{{ version }}/mongodb-enterprise-operator-{{ version }}-linux-x86_64.tar.gz -o /opt/mongodb-enterprise-opertator.tar.gz + RUN tar --strip-components=1 -zxf ./mongodb-enterprise-operator.tar.gz && rm -fv ./mongodb-enterprise-operator.tar.gz +{% endblock %} + +{% block install_operator %} + RUN mv ./mongodb-enterprise-operator /usr/local/bin/ +{% endblock %} + +{% block healthcheck %} +HEALTHCHECK --timeout=30s CMD which mongodb-enterprise-operator || exit 1 +{% endblock %} diff --git a/docker/mongodb-enterprise-operator/Dockerfile.template b/docker/mongodb-enterprise-operator/Dockerfile.template new file mode 100644 index 000000000..23166bbf2 --- /dev/null +++ b/docker/mongodb-enterprise-operator/Dockerfile.template @@ -0,0 +1,40 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM {{ base_image }} + +{% block labels %} +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="{{ release_version }}" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" +{% endblock %} + +{% block packages -%} +{% endblock -%} + +{% block static %} +{% endblock %} + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + +{% if debug|default(false) %} +COPY --from=base /data/dlv /usr/local/bin/dlv +{% endif %} + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + +{% block healthcheck %} +{% endblock %} diff --git a/docker/mongodb-enterprise-operator/Dockerfile.ubi b/docker/mongodb-enterprise-operator/Dockerfile.ubi new file mode 100644 index 000000000..0a31a36c3 --- /dev/null +++ b/docker/mongodb-enterprise-operator/Dockerfile.ubi @@ -0,0 +1,11 @@ +{% extends "Dockerfile.template" %} + +{% set base_image = "registry.access.redhat.com/ubi8/ubi-minimal" %} + +{% block packages -%} +# Building an UBI-based image: https://red.ht/3n6b9y0 +RUN microdnf update \ + --disableplugin=subscription-manager \ + --disablerepo=* --enablerepo=ubi-8-appstream-rpms --enablerepo=ubi-8-baseos-rpms -y \ + && rm -rf /var/cache/yum +{% endblock -%} diff --git a/docker/mongodb-enterprise-operator/LICENSE b/docker/mongodb-enterprise-operator/LICENSE new file mode 100644 index 000000000..b5ca95091 --- /dev/null +++ b/docker/mongodb-enterprise-operator/LICENSE @@ -0,0 +1,4 @@ +Usage of the MongoDB Enterprise Operator for Kubernetes indicates +agreement with the MongoDB Development, Test, and Evaluation Agreement + +* https://www.mongodb.com/legal/evaluation-agreement diff --git a/docker/mongodb-enterprise-operator/README.md b/docker/mongodb-enterprise-operator/README.md new file mode 100644 index 000000000..16d751fee --- /dev/null +++ b/docker/mongodb-enterprise-operator/README.md @@ -0,0 +1,21 @@ +# MongoDB Enterprise Kubernetes Operator + +This directory hosts the Dockerfile for the Ops Manager Operator. + +### Building the source-code + +```bash +CGO_ENABLED=0 GOOS=linux GOFLAGS="-mod=vendor" go build -i -o mongodb-enterprise-operator +``` + +### Building the image + +```bash +docker build -t mongodb-enterprise-operator:0.1 . +``` + +### Running locally + +```bash +docker run -e OPERATOR_ENV=local -e MONGODB_ENTERPRISE_DATABASE_IMAGE=mongodb-enterprise-database -e IMAGE_PULL_POLICY=Never mongodb-enterprise-operator:0.1 +``` diff --git a/docker/mongodb-enterprise-operator/content/.keep b/docker/mongodb-enterprise-operator/content/.keep new file mode 100644 index 000000000..f9c716292 --- /dev/null +++ b/docker/mongodb-enterprise-operator/content/.keep @@ -0,0 +1 @@ +This dir needs to exist to support the build process. diff --git a/docker/mongodb-enterprise-operator/licenses/Apache-2.0/LICENSE b/docker/mongodb-enterprise-operator/licenses/Apache-2.0/LICENSE new file mode 100644 index 000000000..15555da25 --- /dev/null +++ b/docker/mongodb-enterprise-operator/licenses/Apache-2.0/LICENSE @@ -0,0 +1,60 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: +(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and +(b) You must cause any modified files to carry prominent notices stating that You changed the files; and +(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and +(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. +You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/docker/mongodb-enterprise-operator/licenses/Apache-2.0/README b/docker/mongodb-enterprise-operator/licenses/Apache-2.0/README new file mode 100644 index 000000000..802038a74 --- /dev/null +++ b/docker/mongodb-enterprise-operator/licenses/Apache-2.0/README @@ -0,0 +1,49 @@ +# Components licensed under: Apache License Version 2.0 + +## github.com/prometheus/client_model/go + +## k8s.io/apimachinery + +## k8s.io/utils + +## gomodules.xyz/jsonpatch/v2 + +## github.com/xdg/stringprep + +## gopkg.in/yaml.v2 + +## github.com/prometheus/common + +## github.com/matttproud/golang_protobuf_extensions/pbutil + +## k8s.io/kube-openapi/pkg/util/proto + +## github.com/prometheus/procfs + +## github.com/modern-go/reflect2 + +## github.com/googleapis/gnostic + +## github.com/google/gofuzz + +## k8s.io/klog + +## sigs.k8s.io/controller-runtime + +## k8s.io/client-go + +## github.com/golang/groupcache/lru + +## github.com/prometheus/client_golang/prometheus + +## k8s.io/api + +## github.com/modern-go/concurrent + +## github.com/go-logr/logr + +## k8s.io/apiextensions-apiserver/pkg/apis/apiextensions + +## bson@1.1.1 + +## require_optional@1.0.1 diff --git a/docker/mongodb-enterprise-operator/licenses/BSD-2-Clause/LICENSE b/docker/mongodb-enterprise-operator/licenses/BSD-2-Clause/LICENSE new file mode 100644 index 000000000..07c0aac54 --- /dev/null +++ b/docker/mongodb-enterprise-operator/licenses/BSD-2-Clause/LICENSE @@ -0,0 +1,9 @@ +BSD 2-Clause License + +Copyright (c) . All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docker/mongodb-enterprise-operator/licenses/BSD-2-Clause/README b/docker/mongodb-enterprise-operator/licenses/BSD-2-Clause/README new file mode 100644 index 000000000..cfefaad50 --- /dev/null +++ b/docker/mongodb-enterprise-operator/licenses/BSD-2-Clause/README @@ -0,0 +1,3 @@ +# Components licensed under: BSD 2-Clause License + +## github.com/pkg/errors diff --git a/docker/mongodb-enterprise-operator/licenses/BSD-3-Clause/LICENSE b/docker/mongodb-enterprise-operator/licenses/BSD-3-Clause/LICENSE new file mode 100644 index 000000000..bfdf56636 --- /dev/null +++ b/docker/mongodb-enterprise-operator/licenses/BSD-3-Clause/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) [year], [fullname] +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docker/mongodb-enterprise-operator/licenses/BSD-3-Clause/README b/docker/mongodb-enterprise-operator/licenses/BSD-3-Clause/README new file mode 100644 index 000000000..d8ab70737 --- /dev/null +++ b/docker/mongodb-enterprise-operator/licenses/BSD-3-Clause/README @@ -0,0 +1,37 @@ +# Components licensed under: BSD 3-Clause License + +## gopkg.in/fsnotify.v1 + +## golang.org/x/oauth2 + +## golang.org/x/time/rate + +## golang.org/x/crypto/ssh/terminal + +## github.com/pmezard/go-difflib/difflib + +## github.com/gogo/protobuf + +## github.com/spf13/pflag + +## github.com/golang/protobuf + +## github.com/prometheus/common/internal/bitbucket.org/ww/goautoneg + +## github.com/evanphx/json-patch + +## github.com/google/go-cmp/cmp + +## gopkg.in/inf.v0 + +## golang.org/x/net + +## golang.org/x/sys/unix + +## github.com/google/uuid + +## golang.org/x/xerrors + +## github.com/imdario/mergo + +## golang.org/x/text diff --git a/docker/mongodb-enterprise-operator/licenses/ISC/LICENSE b/docker/mongodb-enterprise-operator/licenses/ISC/LICENSE new file mode 100644 index 000000000..4105f19d4 --- /dev/null +++ b/docker/mongodb-enterprise-operator/licenses/ISC/LICENSE @@ -0,0 +1,8 @@ +ISC License + +Copyright (c) 2004-2010 by Internet Systems Consortium, Inc. ("ISC") +Copyright (c) 1995-2003 by Internet Software Consortium + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/docker/mongodb-enterprise-operator/licenses/ISC/README b/docker/mongodb-enterprise-operator/licenses/ISC/README new file mode 100644 index 000000000..eb9dcf0c4 --- /dev/null +++ b/docker/mongodb-enterprise-operator/licenses/ISC/README @@ -0,0 +1,3 @@ +# Components licensed under: ISC License + +## github.com/davecgh/go-spew/spew diff --git a/docker/mongodb-enterprise-operator/licenses/MIT/LICENSE b/docker/mongodb-enterprise-operator/licenses/MIT/LICENSE new file mode 100644 index 000000000..333c3b09d --- /dev/null +++ b/docker/mongodb-enterprise-operator/licenses/MIT/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docker/mongodb-enterprise-operator/licenses/MIT/README b/docker/mongodb-enterprise-operator/licenses/MIT/README new file mode 100644 index 000000000..1b3ba408a --- /dev/null +++ b/docker/mongodb-enterprise-operator/licenses/MIT/README @@ -0,0 +1,29 @@ +# Components licensed under: MIT License + +## go.uber.org/multierr + +## github.com/spf13/cast + +## github.com/blang/semver + +## go.uber.org/zap + +## github.com/beorn7/perks/quantile + +## github.com/stretchr/testify/assert + +## go.uber.org/atomic + +## github.com/json-iterator/go + +## sigs.k8s.io/yaml + +## memory-pager@1.5.0 + +## resolve-from@2.0.0 + +## safe-buffer@5.2.0 + +## saslprep@1.0.3 + +## sparse-bitfield@3.0.3 diff --git a/docker/mongodb-enterprise-operator/licenses/MPL-2.0/LICENSE b/docker/mongodb-enterprise-operator/licenses/MPL-2.0/LICENSE new file mode 100644 index 000000000..91a6cb7ca --- /dev/null +++ b/docker/mongodb-enterprise-operator/licenses/MPL-2.0/LICENSE @@ -0,0 +1,111 @@ +Mozilla Public License Version 2.0 + +1. Definitions +1.1. "Contributor" means each individual or legal entity that creates, contributes to the creation of, or owns Covered Software. +1.2. "Contributor Version" means the combination of the Contributions of others (if any) used by a Contributor and that particular Contributor's Contribution. +1.3. "Contribution" means Covered Software of a particular Contributor. +1.4. "Covered Software" means Source Code Form to which the initial Contributor has attached the notice in Exhibit A, the Executable Form of such Source Code Form, and Modifications of such Source Code Form, in each case including portions thereof. +1.5. "Incompatible With Secondary Licenses" means +(a) that the initial Contributor has attached the notice described in Exhibit B to the Covered Software; or +(b) that the Covered Software was made available under the terms of version 1.1 or earlier of the License, but not also under the terms of a Secondary License. +1.6. "Executable Form" means any form of the work other than Source Code Form. +1.7. "Larger Work" means a work that combines Covered Software with other material, in a separate file or files, that is not Covered Software. +1.8. "License" means this document. +1.9. "Licensable" means having the right to grant, to the maximum extent possible, whether at the time of the initial grant or subsequently, any and all of the rights conveyed by this License. +1.10. "Modifications" means any of the following: +(a) any file in Source Code Form that results from an addition to, deletion from, or modification of the contents of Covered Software; or +(b) any new file in Source Code Form that contains any Covered Software. +1.11. "Patent Claims" of a Contributor means any patent claim(s), including without limitation, method, process, and apparatus claims, in any patent Licensable by such Contributor that would be infringed, but for the grant of the License, by the making, using, selling, offering for sale, having made, import, or transfer of either its Contributions or its Contributor Version. +1.12. "Secondary License" means either the GNU General Public License, Version 2.0, the GNU Lesser General Public License, Version 2.1, the GNU Affero General Public License, Version 3.0, or any later versions of those licenses. +1.13. "Source Code Form" means the form of the work preferred for making modifications. +1.14. "You" (or "Your") means an individual or a legal entity exercising rights under this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with You. For purposes of this definition, "control" means (a) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (b) ownership of more than fifty percent (50%) of the outstanding shares or beneficial ownership of such entity. +2. License Grants and Conditions +2.1. Grants +Each Contributor hereby grants You a world-wide, royalty-free, non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) Licensable by such Contributor to use, reproduce, make available, modify, display, perform, distribute, and otherwise exploit its Contributions, either on an unmodified basis, with Modifications, or as part of a Larger Work; and +(b) under Patent Claims of such Contributor to make, use, sell, offer for sale, have made, import, and otherwise transfer either its Contributions or its Contributor Version. +2.2. Effective Date +The licenses granted in Section 2.1 with respect to any Contribution become effective for each Contribution on the date the Contributor first distributes such Contribution. + +2.3. Limitations on Grant Scope +The licenses granted in this Section 2 are the only rights granted under this License. No additional rights or licenses will be implied from the distribution or licensing of Covered Software under this License. Notwithstanding Section 2.1(b) above, no patent license is granted by a Contributor: + +(a) for any code that a Contributor has removed from Covered Software; or +(b) for infringements caused by: (i) Your and any other third party's modifications of Covered Software, or (ii) the combination of its Contributions with other software (except as part of its Contributor Version); or +(c) under Patent Claims infringed by Covered Software in the absence of its Contributions. +This License does not grant any rights in the trademarks, service marks, or logos of any Contributor (except as may be necessary to comply with the notice requirements in Section 3.4). + +2.4. Subsequent Licenses +No Contributor makes additional grants as a result of Your choice to distribute the Covered Software under a subsequent version of this License (see Section 10.2) or under the terms of a Secondary License (if permitted under the terms of Section 3.3). + +2.5. Representation +Each Contributor represents that the Contributor believes its Contributions are its original creation(s) or it has sufficient rights to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use +This License is not intended to limit any rights You have under applicable copyright doctrines of fair use, fair dealing, or other equivalents. + +2.7. Conditions +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in Section 2.1. + +3. Responsibilities +3.1. Distribution of Source Form +All distribution of Covered Software in Source Code Form, including any Modifications that You create or to which You contribute, must be under the terms of this License. You must inform recipients that the Source Code Form of the Covered Software is governed by the terms of this License, and how they can obtain a copy of this License. You may not attempt to alter or restrict the recipients' rights in the Source Code Form. + +3.2. Distribution of Executable Form +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code Form, as described in Section 3.1, and You must inform recipients of the Executable Form how they can obtain a copy of such Source Code Form by reasonable means in a timely manner, at a charge no more than the cost of distribution to the recipient; and +(b) You may distribute such Executable Form under the terms of this License, or sublicense it under different terms, provided that the license for the Executable Form does not attempt to limit or alter the recipients' rights in the Source Code Form under this License. +3.3. Distribution of a Larger Work +You may create and distribute a Larger Work under terms of Your choice, provided that You also comply with the requirements of this License for the Covered Software. If the Larger Work is a combination of Covered Software with a work governed by one or more Secondary Licenses, and the Covered Software is not Incompatible With Secondary Licenses, this License permits You to additionally distribute such Covered Software under the terms of such Secondary License(s), so that the recipient of the Larger Work may, at their option, further distribute the Covered Software under the terms of either this License or such Secondary License(s). + +3.4. Notices +You may not remove or alter the substance of any license notices (including copyright notices, patent notices, disclaimers of warranty, or limitations of liability) contained within the Source Code Form of the Covered Software, except that You may alter any license notices to the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms +You may choose to offer, and to charge a fee for, warranty, support, indemnity or liability obligations to one or more recipients of Covered Software. However, You may do so only on Your own behalf, and not on behalf of any Contributor. You must make it absolutely clear that any such warranty, support, indemnity, or liability obligation is offered by You alone, and You hereby agree to indemnify every Contributor for any liability incurred by such Contributor as a result of warranty, support, indemnity or liability terms You offer. You may include additional disclaimers of warranty and limitations of liability specific to any jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +If it is impossible for You to comply with any of the terms of this License with respect to some or all of the Covered Software due to statute, judicial order, or regulation then You must: (a) comply with the terms of this License to the maximum extent possible; and (b) describe the limitations and the code they affect. Such description must be placed in a text file included with all distributions of the Covered Software under this License. Except to the extent prohibited by statute or regulation, such description must be sufficiently detailed for a recipient of ordinary skill to be able to understand it. + +5. Termination +5.1. The rights granted under this License will terminate automatically if You fail to comply with any of its terms. However, if You become compliant, then the rights granted under this License from a particular Contributor are reinstated (a) provisionally, unless and until such Contributor explicitly and finally terminates Your grants, and (b) on an ongoing basis, if such Contributor fails to notify You of the non-compliance by some reasonable means prior to 60 days after You have come back into compliance. Moreover, Your grants from a particular Contributor are reinstated on an ongoing basis if such Contributor notifies You of the non-compliance by some reasonable means, this is the first time You have received notice of non-compliance with this License from such Contributor, and You become compliant prior to 30 days after Your receipt of the notice. +5.2. If You initiate litigation against any entity by asserting a patent infringement claim (excluding declaratory judgment actions, counter-claims, and cross-claims) alleging that a Contributor Version directly or indirectly infringes any patent, then the rights granted to You by any and all Contributors for the Covered Software under Section 2.1 of this License shall terminate. +5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user license agreements (excluding distributors and resellers) which have been validly granted by You or Your distributors under this License prior to termination shall survive termination. +6. Disclaimer of Warranty +Covered Software is provided under this License on an "as is" basis, without warranty of any kind, either expressed, implied, or statutory, including, without limitation, warranties that the Covered Software is free of defects, merchantable, fit for a particular purpose or non-infringing. The entire risk as to the quality and performance of the Covered Software is with You. Should any Covered Software prove defective in any respect, You (not any Contributor) assume the cost of any necessary servicing, repair, or correction. This disclaimer of warranty constitutes an essential part of this License. No use of any Covered Software is authorized under this License except under this disclaimer. + +7. Limitation of Liability +Under no circumstances and under no legal theory, whether tort (including negligence), contract, or otherwise, shall any Contributor, or anyone who distributes Covered Software as permitted above, be liable to You for any direct, indirect, special, incidental, or consequential damages of any character including, without limitation, damages for lost profits, loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses, even if such party shall have been informed of the possibility of such damages. This limitation of liability shall not apply to liability for death or personal injury resulting from such party's negligence to the extent applicable law prohibits such limitation. Some jurisdictions do not allow the exclusion or limitation of incidental or consequential damages, so this exclusion and limitation may not apply to You. + +8. Litigation +Any litigation relating to this License may be brought only in the courts of a jurisdiction where the defendant maintains its principal place of business and such litigation shall be governed by laws of that jurisdiction, without reference to its conflict-of-law provisions. Nothing in this Section shall prevent a party's ability to bring cross-claims or counter-claims. + +9. Miscellaneous +This License represents the complete agreement concerning the subject matter hereof. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. Any law or regulation which provides that the language of a contract shall be construed against the drafter shall not be used to construe this License against a Contributor. + +10. Versions of the License +10.1. New Versions +Mozilla Foundation is the license steward. Except as provided in Section 10.3, no one other than the license steward has the right to modify or publish new versions of this License. Each version will be given a distinguishing version number. + +10.2. Effect of New Versions +You may distribute the Covered Software under the terms of the version of the License under which You originally received the Covered Software, or under the terms of any subsequent version published by the license steward. + +10.3. Modified Versions +If you create software not governed by this License, and you want to create a new license for such software, you may create and use a modified version of this License if you rename the license and remove any references to the name of the license steward (except to note that such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses +If You choose to distribute Source Code Form that is Incompatible With Secondary Licenses under the terms of this version of the License, the notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice + +This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular file, then You may include the notice in a location (such as a LICENSE file in a relevant directory) where a recipient would be likely to look for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice + +This Source Code Form is "Incompatible With Secondary Licenses", as defined by the Mozilla Public License, v. 2.0. diff --git a/docker/mongodb-enterprise-operator/licenses/MPL-2.0/README b/docker/mongodb-enterprise-operator/licenses/MPL-2.0/README new file mode 100644 index 000000000..827fcbf17 --- /dev/null +++ b/docker/mongodb-enterprise-operator/licenses/MPL-2.0/README @@ -0,0 +1,7 @@ +# Components licensed under: Mozilla Public License Version 2.0 + +## github.com/hashicorp/golang-lru + +## github.com/hashicorp/go-multierror + +## github.com/hashicorp/errwrap diff --git a/docker/mongodb-enterprise-operator/licenses/THIRD-PARTY-NOTICES b/docker/mongodb-enterprise-operator/licenses/THIRD-PARTY-NOTICES new file mode 100644 index 000000000..b014dbeba --- /dev/null +++ b/docker/mongodb-enterprise-operator/licenses/THIRD-PARTY-NOTICES @@ -0,0 +1,18 @@ +Third-party notices +------------------- + +This product uses third-party libraries or other resources that may +be distributed under licenses different than the MongoDB software. + +Please contact the MongoDB Legal Department if you need a required license to +be added this list, or you would like a copy of the source code from a library in this list. + + https://www.mongodb.com/contact?jmp=docs + +The attached notices are provided for information only. + +Source code for open source libraries can be obtained from MongoDB. + +In the case of components licensed under multiple licenses, +MongoDB has indicated which license it elects by listing the component +under the corresponding license sub-directory. diff --git a/docker/mongodb-enterprise-ops-manager/Dockerfile.builder b/docker/mongodb-enterprise-ops-manager/Dockerfile.builder new file mode 100644 index 000000000..6b389887d --- /dev/null +++ b/docker/mongodb-enterprise-ops-manager/Dockerfile.builder @@ -0,0 +1,3 @@ +FROM scratch + +ADD LICENSE /data/licenses/ diff --git a/docker/mongodb-enterprise-ops-manager/Dockerfile.dcar b/docker/mongodb-enterprise-ops-manager/Dockerfile.dcar new file mode 100644 index 000000000..639c7930b --- /dev/null +++ b/docker/mongodb-enterprise-ops-manager/Dockerfile.dcar @@ -0,0 +1,25 @@ +{% extends "Dockerfile.ubi" %} + + +{% block packages %} +RUN yum install --disableplugin=subscription-manager \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses +{% endblock %} + +{% block healthcheck %} +HEALTHCHECK --timeout=30s CMD ls /mongodb-ops-manager/bin/mongodb-mms || exit 1 +{% endblock %} diff --git a/docker/mongodb-enterprise-ops-manager/Dockerfile.template b/docker/mongodb-enterprise-ops-manager/Dockerfile.template new file mode 100644 index 000000000..b23dbf19e --- /dev/null +++ b/docker/mongodb-enterprise-ops-manager/Dockerfile.template @@ -0,0 +1,59 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM {{ base_image }} + +{% block labels %} +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="{{ om_version }}" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" +{% endblock %} + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally +{% block packages %} +{% endblock %} + +COPY --from=base /data/licenses /licenses/ + + +{% block static %} +RUN curl --fail -L -o ops_manager.tar.gz {{ om_download_url }} \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" +{% endblock %} + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + +{% block healthcheck %} +{% endblock %} diff --git a/docker/mongodb-enterprise-ops-manager/Dockerfile.ubi b/docker/mongodb-enterprise-ops-manager/Dockerfile.ubi new file mode 100644 index 000000000..8180d8656 --- /dev/null +++ b/docker/mongodb-enterprise-ops-manager/Dockerfile.ubi @@ -0,0 +1,23 @@ +{% extends "Dockerfile.template" %} + +{% set base_image = "registry.access.redhat.com/ubi8/ubi-minimal" %} + +{% block packages %} +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses +{% endblock %} diff --git a/docker/mongodb-enterprise-ops-manager/LICENSE b/docker/mongodb-enterprise-ops-manager/LICENSE new file mode 100644 index 000000000..b5ca95091 --- /dev/null +++ b/docker/mongodb-enterprise-ops-manager/LICENSE @@ -0,0 +1,4 @@ +Usage of the MongoDB Enterprise Operator for Kubernetes indicates +agreement with the MongoDB Development, Test, and Evaluation Agreement + +* https://www.mongodb.com/legal/evaluation-agreement diff --git a/docker/mongodb-enterprise-tests/.dockerignore b/docker/mongodb-enterprise-tests/.dockerignore new file mode 100644 index 000000000..300e35ad9 --- /dev/null +++ b/docker/mongodb-enterprise-tests/.dockerignore @@ -0,0 +1,3 @@ +venv/* +__pycache__/* +**/*.pyc diff --git a/docker/mongodb-enterprise-tests/.flake8 b/docker/mongodb-enterprise-tests/.flake8 new file mode 100644 index 000000000..c321e71c9 --- /dev/null +++ b/docker/mongodb-enterprise-tests/.flake8 @@ -0,0 +1,5 @@ +[flake8] +ignore = E203, E266, E501, W503 +max-line-length = 80 +max-complexity = 18 +select = B,C,E,F,W,T4,B9 diff --git a/docker/mongodb-enterprise-tests/.pylintrc b/docker/mongodb-enterprise-tests/.pylintrc new file mode 100644 index 000000000..cc3929d41 --- /dev/null +++ b/docker/mongodb-enterprise-tests/.pylintrc @@ -0,0 +1,10 @@ +[GENERAL] +max-line-length=120 + +[MESSAGES CONTROL] +disable= + C0114, # missing-module-docstring + C0115, # missing-class-docstring + C0116, # missing-docstring + R0201, # no-self-use + W0621, # redefined-outer-name diff --git a/docker/mongodb-enterprise-tests/Dockerfile b/docker/mongodb-enterprise-tests/Dockerfile new file mode 100644 index 000000000..fbc10027f --- /dev/null +++ b/docker/mongodb-enterprise-tests/Dockerfile @@ -0,0 +1,50 @@ +# This image is based on latest Python 3.6 release in latest Debian Stretch. +# I had to move away from Alpine as the latest Kubernetes Python module depends +# on `cryptography` which can be installed in Debian but needs to be compiled +# in Alpine, meaning that we would have to install gcc or clang on it, making +# it too slow for the images. +# +# Ref: https://cryptography.io/en/latest/installation/#building-cryptography-on-linux +# +FROM --platform=linux/amd64 python:3.9-slim as builder + + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + curl libldap2-dev libsasl2-dev build-essential + +COPY requirements.txt requirements.txt + +RUN python3 -m venv /venv && . /venv/bin/activate && python3 -m pip install -r requirements.txt + + +FROM --platform=linux/amd64 python:3.9-slim + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + curl \ + libldap2-dev \ + libsasl2-dev \ + git \ + openssl + +ENV HELM_NAME "helm-v3.6.3-linux-amd64.tar.gz" +# install Helm +RUN curl --fail --retry 3 -L -o "${HELM_NAME}" "https://get.helm.sh/${HELM_NAME}" \ + && tar -xzf "${HELM_NAME}" \ + && rm "${HELM_NAME}" \ + && mv "linux-amd64/helm" "/usr/local/bin/helm" + +COPY --from=builder /venv /venv + +ENV PATH="/venv/bin:${PATH}" + +RUN mkdir /tests +WORKDIR /tests + +# copying the test files after python build, otherwise pip install will be called each time the tests change +COPY . /tests +# copying the helm_chart directory as well to support installation of the Operator from the test application +COPY helm_chart /helm_chart + +ADD multi-cluster-kube-config-creator /usr/local/bin/multi-cluster-kube-config-creator diff --git a/docker/mongodb-enterprise-tests/README.md b/docker/mongodb-enterprise-tests/README.md new file mode 100644 index 000000000..4e750927b --- /dev/null +++ b/docker/mongodb-enterprise-tests/README.md @@ -0,0 +1,239 @@ +# Enterprise Kubernetes E2E Testing # + +The MongoDB Enterprise Kubernetes Operator uses a declarative testing +framwork based on the Python programming language and the Pytest +module. Its design allow the developer to write tests in a +declarative way and verify the results by using a collection of helper +functions that check the state of the Kubernetes cluster. + +# Quick start # + +The goal of this guide is to allow you to run your tests locally as +regular Python tests. The resulting "experience" should be something +like: + +``` bash +$ pytest -m e2e_replica_set +===================================================================== test session starts =========================================================== +platform linux -- Python 3.6.8, pytest-4.3.1, py-1.8.0, pluggy-0.9.0 -- /home/rvalin/.virtualenvs/operator-tests/bin/python +cachedir: .pytest_cache +rootdir: /home/rvalin/workspace/go/src/github.com/10gen/ops-manager-kubernetes/docker/mongodb-enterprise-tests, inifile: pytest.ini +collected 168 items / 131 deselected / 37 selected +tests/replicaset/replica_set.py::TestReplicaSetCreation::test_replica_set_sts_exists PASSED [ 2%] +tests/replicaset/replica_set.py::TestReplicaSetCreation::test_sts_creation PASSED [ 5%] +tests/replicaset/replica_set.py::TestReplicaSetCreation::test_sts_metadata PASSED [ 8%] +tests/replicaset/replica_set.py::TestReplicaSetCreation::test_sts_replicas PASSED [ 10%] +tests/replicaset/replica_set.py::TestReplicaSetCreation::test_sts_template PASSED [ 13%] + +... + +tests/replicaset/replica_set.py::TestReplicaSetCreation::test_replica_set_was_configured SKIPPED [ 51%] +tests/replicaset/replica_set.py::TestReplicaSetUpdate::test_replica_set_sts_should_exist PASSED [ 54%] +tests/replicaset/replica_set.py::TestReplicaSetUpdate::test_sts_update PASSED [ 56%] +tests/replicaset/replica_set.py::TestReplicaSetUpdate::test_sts_metadata PASSED [ 59%] + +... + +tests/replicaset/replica_set.py::TestReplicaSetUpdate::test_backup PASSED [ 94%] +tests/replicaset/replica_set.py::TestReplicaSetDelete::test_replica_set_sts_doesnt_exist PASSED [ 97%] +tests/replicaset/replica_set.py::TestReplicaSetDelete::test_service_does_not_exist PASSED [100%] +============================================= 36 passed, 1 skipped, 131 deselected, 10 warnings in 89.47 seconds ==================================== + +``` + +This is a full E2E experience running locally that takes about 90 +seconds. + +## Configuring/Installing dependencies ## + +In order to run, the local E2E tests need certain dependant +components: + +* Ops Manager >= 4.0 +* `kubectl` context to use configured Kubernetes Cluster +* Operator `Project` and `Credentials` created +* Kubernetes Namespace created +* Python 3.6 and dependencies + +Most of the required configuration is achieved by using `make` at the +root of the project. [This +document](https://github.com/10gen/ops-manager-kubernetes/blob/master/docs/dev/dev-start-guide.md) +will guide you on how to do this. + +The result of this is to have a running Ops Manager with +configurations saved on the `~/.operator-dev/om` file. This file will +be read by the E2E testing framework to configure itself. Once you +have done this, you can proceed to complete the Python installation. + +### Installing Python and Dependencies ### + +You should have Python 3.6 installed, create a virtualenv and install +the dependencies on the `requirements.txt` file. The following is an +example on how to achieve this: + + +* First time install only: Create a virtualenv +``` bash +python3.6 -m pip install venv --user +python3.6 -m venv venv + +source venv/bin/activate +python -m pip install -r requirements.txt +``` + +* After the first run, when coming back to the project it should be +required to `activate` your virtual environment once again. +``` bash +source venv/bin/activate +``` + +There are many mechanisms to achieve this in a more automated way, +I'll leave that to each person to decide the one that suits their needs. + +# Running the E2E Tests Locally # + + +The tests are defined in terms of "markers" in the Python source +files. To run each test, you need to specify which "marker" you want +to run, for instance: + +``` bash +# Run the Replica Set Enterprise Installation E2E Test +pytest -m e2e_replica_set + +# Run the TLS Upgrade E2E Test +pytest -m e2e_replica_set_tls_require_upgrade +``` + +These markers correspond to the name of the `task` in Evergreen. It is +handy as they can be copied over the command line to try the tests +locally, that usually run faster. + +To find the list of available E2E tasks you can do the following: + + grep pytest.mark.e2e * -R | sort | uniq | cut -d "@" -f 2 + +The `@pytest.mark.` syntax is a class annotation we use to +indicate which test classes need to be run. But for now they help us +to call a particular E2E task we are interested in. + + +# Writing New Tests # + +### Create a new Python test file ### + +Create a new Python file that will hold the test, the contents of this +file will be something like: + +``` python +import pytest +from kubetester.kubetester import KubernetesTester +from kubernetes import client + + +@pytest.mark.e2e_my_new_feature +class TestMyNewFeatureShouldPass(KubernetesTester): + """ + name: My New Feature Test 01 + create: + file: my-mdb-object-to-test.yaml + wait_until: in_running_state + """ + + def test_something_about_my_new_object(self): + mdb_object_name = "my-mdb-object-to-test" + mdb = client.CustomObjectsApi().get_namespaced_custom_object( + "mongodb.com", "v1", self.namespace, "mongodb", mdb_object_name + ) + + assert mdb["status"]["phase"] == "Running" +``` + +The `my-mdb-object-to-test.yaml` will reside in one of the `fixtures` +directories and contain something like: + +``` yaml +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: my-mdb-object-to-test +spec: + version: 4.0.8 + type: Standalone + project: my-project + credentials: my-credentials + persistent: false +``` + +Check the `@pytest.mark` decorator we have set for this test class, this is +what we'll use to run the test locally with: + +``` bash +$ pytest -m e2e_my_new_feature +========================================== test session starts =========================================== +platform linux -- Python 3.6.8, pytest-4.3.1, py-1.8.0, pluggy-0.9.0 -- /home/rvalin/.virtualenvs/operator-tests/bin/python +cachedir: .pytest_cache +rootdir: /home/rvalin/workspace/go/src/github.com/10gen/ops-manager-kubernetes/docker/mongodb-enterprise-tests, inifile: pytest.ini +collected 170 items / 169 deselected / 1 selected +tests/mixed/sample_test.py::TestMyNewFeatureShouldPass::test_something_about_my_new_object PASSED [100%] +=============================== 1 passed, 169 deselected in 40.62 seconds ================================ +``` + +In this run `pytest` was able to find our e2e test with the name +`e2e_my_new_feature`, the same we used in the `pytest.mark` decorator +and it run by finding our Kubernetes environment by reading `kubectl` +configuration. + +### Make sure your test resources are removed! ### + +There are a few handy functions to have your MDB resources removed, +the usual path to do this is to have a new function that will remove +the object for you: + +``` python +def test_mdb_object_is_removed(self): + mdb_object_name = "my-mdb-object-to-test" + delete_opts = client.V1DeleteOptions() + + mdb = client.CustomObjectsApi().delete_namespaced_custom_object( + "mongodb.com", "v1", self.namespace, "mongodb", mdb_object_name, body=delete_opts + ) + + assert mdb["status"] == "Success" +``` + +### Finally, add an entry into Evergreen ### + +This is a 2-step process, which requires adding a new E2E task and add +this new task into a task group. + +First you add a new task, under the `tasks` dictionary in the +`evergreen.yaml` file: + +``` yaml +tasks: +- name: e2e_my_new_feature + commands: + - func: "e2e_test" +``` + +And secondly, add this task into a `task_group`. Find the one that +matches whatever you are testing and add the task in there. If your +task is testing something structural, it should go into +`e2e_core_task_group`, like: + +``` yaml +- name: e2e_core_task_group + max_hosts: 100 + setup_group: + - func: "clone" + - func: "setup_kubectl" + tasks: + - e2e_all_mongodb_resources_parallel + - e2e_standalone_config_map + - .... # more tasks + - e2e_my_new_feature # this is our new task! +``` + +After doing this, your task will be added into the list of tasks +executed by Evergreen on each test run. diff --git a/docker/mongodb-enterprise-tests/kubetester/__init__.py b/docker/mongodb-enterprise-tests/kubetester/__init__.py new file mode 100644 index 000000000..2aaec8851 --- /dev/null +++ b/docker/mongodb-enterprise-tests/kubetester/__init__.py @@ -0,0 +1,457 @@ +import random +import string +import time +from base64 import b64decode +from typing import Any, Callable, Dict, List, Optional + +import kubernetes.client +from kubernetes import client, utils + +from kubeobject import CustomObject +from kubetester.kubetester import run_periodically, running_locally + +# Re-exports +from .kubetester import fixture as find_fixture +from .mongodb import MongoDB +from .security_context import ( + assert_pod_container_security_context, + assert_pod_security_context, +) +from kubernetes import client + + +def create_secret( + namespace: str, + name: str, + data: Dict[str, str], + type: Optional[str] = "Opaque", + api_client: Optional[client.ApiClient] = None, +) -> str: + """Creates a Secret with `name` in `namespace`. String contents are passed as the `data` parameter.""" + secret = client.V1Secret(metadata=client.V1ObjectMeta(name=name), string_data=data, type=type) + + client.CoreV1Api(api_client=api_client).create_namespaced_secret(namespace, secret) + + return name + + +def create_or_update_secret( + namespace: str, + name: str, + data: Dict[str, str], + type: Optional[str] = "Opaque", + api_client: Optional[client.ApiClient] = None, +) -> str: + try: + create_secret(namespace, name, data, type, api_client) + except kubernetes.client.ApiException as e: + if e.status == 409: + update_secret(namespace, name, data, api_client) + + return name + + +def update_secret( + namespace: str, + name: str, + data: Dict[str, str], + api_client: Optional[client.ApiClient] = None, +): + """Updates a secret in a given namespace with the given name and data—handles base64 encoding.""" + secret = client.V1Secret(metadata=client.V1ObjectMeta(name=name), string_data=data) + client.CoreV1Api(api_client=api_client).patch_namespaced_secret(name, namespace, secret) + + +def delete_secret(namespace: str, name: str, api_client: Optional[kubernetes.client.ApiClient] = None): + client.CoreV1Api(api_client=api_client).delete_namespaced_secret(name, namespace) + + +def create_service_account(namespace: str, name: str) -> str: + """Creates a service account with `name` in `namespace`""" + sa = client.V1ServiceAccount(metadata=client.V1ObjectMeta(name=name)) + client.CoreV1Api().create_namespaced_service_account(namespace=namespace, body=sa) + return name + + +def create_or_update_service( + namespace: str, + name: str, + spec: client.V1ServiceSpec, + api_client: Optional[kubernetes.client.ApiClient] = None, +): + """Creates a service with `name` in `namespace`""" + service = client.V1Service(metadata=client.V1ObjectMeta(name=name, namespace=namespace), spec=spec) + try: + client.CoreV1Api(api_client=api_client).create_namespaced_service(namespace=namespace, body=service) + except kubernetes.client.ApiException as e: + if e.status == 409: + client.CoreV1Api(api_client=api_client).patch_namespaced_service(name, namespace, body=service) + else: + raise e + + +def delete_service_account(namespace: str, name: str) -> str: + """Deletes a service account with `name` in `namespace`""" + client.CoreV1Api().delete_namespaced_service_account(namespace=namespace, name=name) + return name + + +def get_service( + namespace: str, name: str, api_client: Optional[kubernetes.client.ApiClient] = None +) -> client.V1ServiceSpec: + """Gets a service with `name` in `namespace. + :return None if the service does not exist + """ + try: + return client.CoreV1Api(api_client=api_client).read_namespaced_service(name, namespace) + except kubernetes.client.ApiException as e: + if e.status == 404: + return None + else: + raise e + + +def delete_pvc(namespace: str, name: str): + """Deletes a persistent volument claim(pvc) with `name` in `namespace`""" + client.CoreV1Api().delete_namespaced_persistent_volume_claim(namespace=namespace, name=name) + + +def create_object_from_dict(data, namespace: str) -> List: + k8s_client = client.ApiClient() + return utils.create_from_dict(k8s_client=k8s_client, data=data, namespace=namespace) + + +def create_configmap( + namespace: str, + name: str, + data: Dict[str, str], + api_client: Optional[kubernetes.client.ApiClient] = None, +): + configmap = client.V1ConfigMap(metadata=client.V1ObjectMeta(name=name), data=data) + client.CoreV1Api(api_client=api_client).create_namespaced_config_map(namespace, configmap) + + +def update_configmap( + namespace: str, + name: str, + data: Dict[str, str], + api_client: Optional[kubernetes.client.ApiClient] = None, +): + configmap = client.V1ConfigMap(metadata=client.V1ObjectMeta(name=name), data=data) + client.CoreV1Api(api_client=api_client).replace_namespaced_config_map(name, namespace, configmap) + + +def create_or_update_configmap( + namespace: str, + name: str, + data: Dict[str, str], + api_client: Optional[kubernetes.client.ApiClient] = None, +) -> str: + print("Logging inside create_or_update configmap") + try: + create_configmap(namespace, name, data, api_client) + except kubernetes.client.ApiException as e: + if e.status == 409: + update_configmap(namespace, name, data, api_client) + + return name + + +def create_service( + namespace: str, + name: str, + cluster_ip: Optional[str] = None, + ports: Optional[List[client.V1ServicePort]] = None, + selector=None, +): + if ports is None: + ports = [] + + service = client.V1Service( + metadata=client.V1ObjectMeta(name=name, namespace=namespace), + spec=client.V1ServiceSpec(ports=ports, cluster_ip=cluster_ip, selector=selector), + ) + client.CoreV1Api().create_namespaced_service(namespace, service) + + +def create_statefulset( + namespace: str, + name: str, + service_name: str, + labels: Dict[str, str], + replicas: int = 1, + containers: Optional[List[client.V1Container]] = None, + volumes: Optional[List[client.V1Volume]] = None, +): + if containers is None: + containers = [] + if volumes is None: + volumes = [] + + sts = client.V1StatefulSet( + metadata=client.V1ObjectMeta(name=name, namespace=namespace), + spec=client.V1StatefulSetSpec( + selector=client.V1LabelSelector(match_labels=labels), + replicas=replicas, + service_name=service_name, + template=client.V1PodTemplateSpec( + metadata=client.V1ObjectMeta(labels=labels), + spec=client.V1PodSpec(containers=containers, volumes=volumes), + ), + ), + ) + client.AppsV1Api().create_namespaced_stateful_set(namespace, body=sts) + + +def read_service( + namespace: str, + name: str, + api_client: Optional[client.ApiClient] = None, +) -> client.V1Service: + return client.CoreV1Api(api_client=api_client).read_namespaced_service(name, namespace) + + +def read_secret( + namespace: str, + name: str, + api_client: Optional[client.ApiClient] = None, +) -> Dict[str, str]: + return decode_secret(client.CoreV1Api(api_client=api_client).read_namespaced_secret(name, namespace).data) + + +def read_configmap( + namespace: str, + name: str, + api_client: Optional[client.ApiClient] = None, +) -> Dict[str, str]: + return client.CoreV1Api(api_client=api_client).read_namespaced_config_map(name, namespace).data + + +def delete_pod(namespace: str, name: str, api_client: Optional[kubernetes.client.ApiClient] = None): + client.CoreV1Api(api_client=api_client).delete_namespaced_pod(name, namespace) + + +def create_or_update_namespace( + namespace: str, + labels: dict = None, + annotations: dict = None, + api_client: Optional[kubernetes.client.ApiClient] = None, +): + namespace_resource = client.V1Namespace( + metadata=client.V1ObjectMeta( + name=namespace, + labels=labels, + annotations=annotations, + ) + ) + try: + client.CoreV1Api(api_client=api_client).create_namespace(namespace_resource) + except kubernetes.client.ApiException as e: + if e.status == 409: + client.CoreV1Api(api_client=api_client).patch_namespace(namespace, namespace_resource) + + +def delete_namespace(name: str): + c = client.CoreV1Api() + c.delete_namespace(name, body=c.V1DeleteOptions()) + + +def delete_deployment(namespace: str, name: str): + client.AppsV1Api().delete_namespaced_deployment(name, namespace) + + +def delete_statefulset( + namespace: str, + name: str, + propagation_policy: str = "Orphan", + api_client: Optional[client.ApiClient] = None, +): + client.AppsV1Api(api_client=api_client).delete_namespaced_stateful_set( + name, namespace, propagation_policy=propagation_policy + ) + + +def get_statefulset( + namespace: str, + name: str, + api_client: Optional[client.ApiClient] = None, +) -> client.V1StatefulSet: + return client.AppsV1Api(api_client=api_client).read_namespaced_stateful_set(name, namespace) + + +def delete_cluster_role(name: str, api_client: Optional[client.ApiClient] = None): + try: + client.RbacAuthorizationV1Api(api_client=api_client).delete_cluster_role(name) + except client.rest.ApiException as e: + if e.status != 404: + raise e + + +def delete_cluster_role_binding(name: str, api_client: Optional[client.ApiClient] = None): + try: + client.RbacAuthorizationV1Api(api_client=api_client).delete_cluster_role_binding(name) + except client.rest.ApiException as e: + if e.status != 404: + raise e + + +def random_k8s_name(prefix=""): + return prefix + "".join(random.choice(string.ascii_lowercase) for _ in range(10)) + + +def get_pod_when_running( + namespace: str, + label_selector: str, + api_client: Optional[kubernetes.client.ApiClient] = None, +) -> client.V1Pod: + """ + Returns a Pod that matches label_selector. It will block until the Pod is in + Running state. + """ + while True: + time.sleep(3) + + try: + pods = client.CoreV1Api(api_client=api_client).list_namespaced_pod(namespace, label_selector=label_selector) + try: + pod = pods.items[0] + except IndexError: + continue + + if pod.status.phase == "Running": + return pod + + except client.rest.ApiException as e: + # The Pod might not exist in Kubernetes yet so skip any 404 + if e.status != 404: + raise + + +def get_pod_when_ready( + namespace: str, + label_selector: str, + api_client: Optional[kubernetes.client.ApiClient] = None, + default_retry: Optional[int] = 20, +) -> client.V1Pod: + """ + Returns a Pod that matches label_selector. It will block until the Pod is in + Ready state. + """ + cnt = 0 + + while True and cnt < default_retry: + print(f": namespace={namespace}, label_selector={label_selector}") + time.sleep(3) + + cnt += 1 + try: + pods = client.CoreV1Api(api_client=api_client).list_namespaced_pod(namespace, label_selector=label_selector) + + if len(pods.items) == 0: + continue + + pod = pods.items[0] + + # This might happen when the pod is still pending + if pod.status.conditions is None: + continue + + for condition in pod.status.conditions: + if condition.type == "Ready" and condition.status == "True": + return pod + + except client.rest.ApiException as e: + # The Pod might not exist in Kubernetes yet so skip any 404 + if e.status != 404: + raise + + print(f"bailed on getting pod ready after 10 retries") + + +def is_pod_ready( + namespace: str, + label_selector: str, + api_client: Optional[kubernetes.client.ApiClient] = None, +) -> client.V1Pod: + """ + Checks if a Pod that matches label_selector is ready. It will return False if the pod is not ready, + if it does not exist or there is any other kind of error. + This function is intended to check if installing third party components is needed. + """ + print(f"Checking if pod is ready: namespace={namespace}, label_selector={label_selector}") + try: + pods = client.CoreV1Api(api_client=api_client).list_namespaced_pod(namespace, label_selector=label_selector) + + if len(pods.items) == 0: + return None + + pod = pods.items[0] + + if pod.status.conditions is None: + return None + + for condition in pod.status.conditions: + if condition.type == "Ready" and condition.status == "True": + return pod + except client.rest.ApiException: + return None + + return None + + +def get_default_storage_class() -> str: + default_class_annotations = ( + "storageclass.kubernetes.io/is-default-class", # storage.k8s.io/v1 + "storageclass.beta.kubernetes.io/is-default-class", # storage.k8s.io/v1beta1 + ) + sc: client.V1StorageClass + for sc in client.StorageV1Api().list_storage_class().items: + if sc.metadata.annotations is not None and any( + sc.metadata.annotations.get(a) == "true" for a in default_class_annotations + ): + return sc.metadata.name + + +def decode_secret(data: Dict[str, str]) -> Dict[str, str]: + return {k: b64decode(v).decode("utf-8") for (k, v) in data.items()} + + +def wait_until(fn: Callable[..., Any], timeout=0, **kwargs): + """ + Runs the Callable `fn` until timeout is reached or until it returns True. + """ + return run_periodically(fn, timeout=timeout, **kwargs) + + +def create_or_update(resource: CustomObject) -> CustomObject: + """ + Tries to create the resource. If resource already exists (resulting in 409 Conflict), + then it updates it instead. + """ + try: + if not resource.bound: + resource.create() + else: + resource.update() + except kubernetes.client.ApiException as e: + if e.status != 409: + raise e + resource.update() + + return resource + + +def try_load(resource: CustomObject) -> bool: + """ + Tries to load the resource without raising an exception when the resource does not exist. + Returns False if the resource does not exist. + """ + try: + resource.load() + except kubernetes.client.ApiException as e: + if e.status != 404: + raise e + else: + return True + + return True diff --git a/docker/mongodb-enterprise-tests/kubetester/automation_config_tester.py b/docker/mongodb-enterprise-tests/kubetester/automation_config_tester.py new file mode 100644 index 000000000..15dbfb653 --- /dev/null +++ b/docker/mongodb-enterprise-tests/kubetester/automation_config_tester.py @@ -0,0 +1,133 @@ +from typing import Dict, Set, Tuple, List, Optional + +from kubetester.kubetester import KubernetesTester + +X509_AGENT_SUBJECT = "CN=mms-automation-agent,OU=MongoDB Kubernetes Operator,O=mms-automation-agent,L=NY,ST=NY,C=US" +SCRAM_AGENT_USER = "mms-automation-agent" + + +class AutomationConfigTester: + """Tester for AutomationConfig. Should be initialized with the + AutomationConfig we will test (`ac`), the expected amount of users, and if it should be + set to `authoritative_set`, which means that the Automation Agent will force the existing + users in MongoDB to be the ones defined in the Automation Config. + """ + + def __init__(self, ac: Optional[Dict] = None): + if ac is None: + ac = KubernetesTester.get_automation_config() + self.automation_config = ac + + def get_replica_set_processes(self, rs_name: str) -> List[Dict]: + """Returns all processes for the specified replica set""" + replica_set = ([rs for rs in self.automation_config["replicaSets"] if rs["_id"] == rs_name])[0] + rs_processes_name = [member["host"] for member in replica_set["members"]] + return [process for process in self.automation_config["processes"] if process["name"] in rs_processes_name] + + def get_replica_set_members(self, rs_name: str) -> List[Dict]: + replica_set = ([rs for rs in self.automation_config["replicaSets"] if rs["_id"] == rs_name])[0] + return replica_set["members"] + + def get_mongos_processes(self): + """ " Returns all mongos processes in deployment. We don't need to filter by sharded cluster name as + we have only a single resource per deployment""" + return [process for process in self.automation_config["processes"] if process["processType"] == "mongos"] + + def assert_expected_users(self, expected_users: int): + automation_config_users = 0 + + for user in self.automation_config["auth"]["usersWanted"]: + if user["user"] != "mms-backup-agent" and user["user"] != "mms-monitoring-agent": + automation_config_users += 1 + + assert automation_config_users == expected_users + + def assert_authoritative_set(self, authoritative_set: bool): + assert self.automation_config["auth"]["authoritativeSet"] == authoritative_set + + def assert_authentication_mechanism_enabled(self, mechanism: str, active_auth_mechanism: bool = True) -> None: + auth: dict = self.automation_config["auth"] + assert mechanism in auth.get("deploymentAuthMechanisms", []) + if active_auth_mechanism: + assert mechanism in auth.get("autoAuthMechanisms", []) + assert auth["autoAuthMechanism"] == mechanism + + def assert_authentication_mechanism_disabled(self, mechanism: str, check_auth_mechanism: bool = True) -> None: + auth = self.automation_config["auth"] + assert mechanism not in auth.get("deploymentAuthMechanisms", []) + assert mechanism not in auth.get("autoAuthMechanisms", []) + if check_auth_mechanism: + assert auth["autoAuthMechanism"] != mechanism + + def assert_authentication_enabled(self, expected_num_deployment_auth_mechanisms: int = 1) -> None: + assert not self.automation_config["auth"]["disabled"] + + actual_num_deployment_auth_mechanisms = len(self.automation_config["auth"].get("deploymentAuthMechanisms", [])) + assert actual_num_deployment_auth_mechanisms == expected_num_deployment_auth_mechanisms + + def assert_internal_cluster_authentication_enabled(self): + for process in self.automation_config["processes"]: + assert process["args2_6"]["security"]["clusterAuthMode"] == "x509" + + def assert_authentication_disabled(self, remaining_users: int = 0) -> None: + assert self.automation_config["auth"]["disabled"] + self.assert_expected_users(expected_users=remaining_users) + assert len(self.automation_config["auth"].get("deploymentAuthMechanisms", [])) == 0 + + def assert_user_has_roles(self, username: str, roles: Set[Tuple[str, str]]) -> None: + user = [user for user in self.automation_config["auth"]["usersWanted"] if user["user"] == username][0] + actual_roles = {(role["db"], role["role"]) for role in user["roles"]} + assert actual_roles == roles + + def assert_has_user(self, username: str) -> None: + assert username in {user["user"] for user in self.automation_config["auth"]["usersWanted"]} + + def assert_agent_user(self, agent_user: str) -> None: + assert self.automation_config["auth"]["autoUser"] == agent_user + + def assert_replica_sets_size(self, expected_size: int): + assert len(self.automation_config["replicaSets"]) == expected_size + + def assert_processes_size(self, expected_size: int): + assert len(self.automation_config["processes"]) == expected_size + + def assert_sharding_size(self, expected_size: int): + assert len(self.automation_config["sharding"]) == expected_size + + def assert_empty(self): + self.assert_processes_size(0) + self.assert_replica_sets_size(0) + self.assert_sharding_size(0) + + def assert_mdb_option(self, process: Dict, value, *keys): + current = process["args2_6"] + for k in keys[:-1]: + current = current[k] + assert current[keys[-1]] == value + + def get_role_at_index(self, index: int) -> Dict: + roles = self.automation_config["roles"] + assert roles is not None + assert len(roles) > index + return roles[index] + + def assert_has_expected_number_of_roles(self, expected_roles: int): + roles = self.automation_config["roles"] + assert len(roles) == expected_roles + + def assert_expected_role(self, role_index: int, expected_value: Dict): + role = self.automation_config["roles"][role_index] + assert role == expected_value + + def assert_tls_client_certificate_mode(self, mode: str): + assert self.automation_config["tls"]["clientCertificateMode"] == mode + + def reached_version(self, version: int) -> bool: + return self.automation_config["version"] == version + + def get_agent_version(self) -> str: + try: + return self.automation_config["agentVersion"]["name"] + except KeyError: + # the agent version can be empty if the /automationConfig/upgrade endpoint hasn't been called yet + return "" diff --git a/docker/mongodb-enterprise-tests/kubetester/awss3client.py b/docker/mongodb-enterprise-tests/kubetester/awss3client.py new file mode 100644 index 000000000..817b379e8 --- /dev/null +++ b/docker/mongodb-enterprise-tests/kubetester/awss3client.py @@ -0,0 +1,53 @@ +import boto3 +from kubetester.kubetester import get_env_var_or_fail +from botocore.exceptions import ClientError +from time import sleep + + +class AwsS3Client: + def __init__(self, region: str): + # these variables are not used in connection as boto3 client uses the env variables though + # it makes sense to fail fast if the env variables are not specified + self.aws_access_key = get_env_var_or_fail("AWS_ACCESS_KEY_ID") + self.aws_secret_access_key = get_env_var_or_fail("AWS_SECRET_ACCESS_KEY") + + self.s3_client = boto3.client("s3", region_name=region) + + def create_s3_bucket(self, name: str): + self.s3_client.create_bucket(ACL="private", Bucket=name) + + def delete_s3_bucket(self, name: str, attempts: int = 10): + v = self.s3_client.list_objects_v2(Bucket=name) + print(v) + if v is not None and "Contents" in v: + for x in v["Contents"]: + self.s3_client.delete_object(Bucket=name, Key=x["Key"]) + + while attempts > 0: + try: + self.s3_client.delete_bucket(Bucket=name) + break + except ClientError: + print("Can't delete bucket, will try again in 5 seconds") + attempts -= 1 + sleep(5) + + def upload_file( + self, file_path: str, bucket: str, object_name: str, public_read: bool = False + ): + """Upload a file to an S3 bucket. + + Args: + file_name: File to upload + bucket: Bucket to upload to + object_name: S3 object name + + Throws botocore.exceptions.ClientError if upload fails + """ + + extraArgs = {"ACL": "public-read"} if public_read else None + self.s3_client.upload_file(file_path, bucket, object_name, extraArgs) + + +def s3_endpoint(aws_region: str) -> str: + return f"s3.{aws_region}.amazonaws.com" diff --git a/docker/mongodb-enterprise-tests/kubetester/certs.py b/docker/mongodb-enterprise-tests/kubetester/certs.py new file mode 100644 index 000000000..bba4beba1 --- /dev/null +++ b/docker/mongodb-enterprise-tests/kubetester/certs.py @@ -0,0 +1,835 @@ +""" +Certificate Custom Resource Definition. +""" + +import collections +import copy +import random +import time +from datetime import datetime, timezone +from typing import Optional, Dict, List, Generator + +import kubernetes +from kubeobject import CustomObject +from kubernetes import client +from kubernetes.client.rest import ApiException + +from kubetester import ( + random_k8s_name, + create_secret, + read_secret, + delete_secret, + create_or_update, + create_or_update_secret, +) +from kubetester.kubetester import KubernetesTester +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from tests.vaultintegration import ( + store_secret_in_vault, + vault_namespace_name, + vault_sts_name, +) + +ISSUER_CA_NAME = "ca-issuer" + +SUBJECT = { + # Organizational Units matches your namespace (to be overriden by test) + "organizationalUnits": ["TO-BE-REPLACED"], +} + +# Defines properties of a set of servers, like a Shard, or Replica Set holding config servers. +# This is almost equivalent to the StatefulSet created. +SetProperties = collections.namedtuple("SetProperties", ["name", "service", "replicas"]) + + +CertificateType = CustomObject.define( + "Certificate", + kind="Certificate", + plural="certificates", + group="cert-manager.io", + version="v1", +) + + +class WaitForConditions: + def is_ready(self): + self.reload() + + if "status" not in self or "conditions" not in self["status"]: + return + + for condition in self["status"]["conditions"]: + if condition["reason"] == self.Reason and condition["status"] == "True" and condition["type"] == "Ready": + return True + + def block_until_ready(self): + while not self.is_ready(): + time.sleep(2) + + +class Certificate(CertificateType, WaitForConditions): + Reason = "Ready" + + +IssuerType = CustomObject.define("Issuer", kind="Issuer", plural="issuers", group="cert-manager.io", version="v1") +ClusterIssuerType = CustomObject.define( + "ClusterIssuer", + kind="ClusterIssuer", + plural="clusterissuers", + group="cert-manager.io", + version="v1", +) + + +class Issuer(IssuerType, WaitForConditions): + Reason = "KeyPairVerified" + + +class ClusterIssuer(ClusterIssuerType, WaitForConditions): + Reason = "KeyPairVerified" + + +def generate_cert( + namespace: str, + pod: str, + dns: str, + issuer: str, + spec: Optional[Dict] = None, + additional_domains: Optional[List[str]] = None, + multi_cluster_mode=False, + api_client: Optional[client.ApiClient] = None, + secret_name: Optional[str] = None, + secret_backend: Optional[str] = None, + vault_subpath: Optional[str] = None, + dns_list: Optional[List[str]] = None, + common_name: Optional[str] = None, + clusterwide: bool = False, +) -> str: + if spec is None: + spec = dict() + + if secret_name is None: + secret_name = "{}-{}".format(pod[0], random_k8s_name(prefix="")[:4]) + + if secret_backend is None: + secret_backend = "Kubernetes" + + cert = Certificate(namespace=namespace, name=secret_name) + + if multi_cluster_mode: + dns_names = dns_list + else: + dns_names = [dns] + + if not multi_cluster_mode: + dns_names.append(pod) + + if additional_domains is not None: + dns_names += additional_domains + + issuerRef = {"name": issuer, "kind": "Issuer"} + if clusterwide: + issuerRef["kind"] = "ClusterIssuer" + + cert["spec"] = { + "dnsNames": dns_names, + "secretName": secret_name, + "issuerRef": issuerRef, + "duration": "240h", + "renewBefore": "120h", + "usages": ["server auth", "client auth"], + } + + # The use of the common name field has been deprecated since 2000 and is + # discouraged from being used. + # However, KMIP still enforces it :( + if common_name is not None: + cert["spec"]["commonName"] = common_name + + cert["spec"].update(spec) + cert.api = kubernetes.client.CustomObjectsApi(api_client=api_client) + create_or_update(cert) + print(f"Waiting for certificate to become ready: {cert}") + cert.block_until_ready() + + if secret_backend == "Vault": + path = "secret/mongodbenterprise/" + if vault_subpath is None: + raise ValueError("When secret backend is Vault, a subpath must be specified") + path += f"{vault_subpath}/{namespace}/{secret_name}" + + data = read_secret(namespace, secret_name) + store_secret_in_vault(vault_namespace_name(), vault_sts_name(), data, path) + cert.delete() + delete_secret(namespace, secret_name) + + return secret_name + + +def create_tls_certs( + issuer: str, + namespace: str, + resource_name: str, + replicas: int = 3, + service_name: str = None, + spec: Optional[Dict] = None, + secret_name: Optional[str] = None, + additional_domains: Optional[List[str]] = None, + secret_backend: Optional[str] = None, + vault_subpath: Optional[str] = None, + common_name: Optional[str] = None, + process_hostnames: Optional[List[str]] = None, +) -> Dict[str, str]: + """ + :param process_hostnames: set for TLS certificate to contain only given domains + """ + if service_name is None: + service_name = resource_name + "-svc" + + if spec is None: + spec = dict() + + pod_fqdn_fstring = "{resource_name}-{index}.{service_name}.{namespace}.svc.cluster.local".format( + resource_name=resource_name, + service_name=service_name, + namespace=namespace, + index="{}", + ) + + pod_dns = [] + pods = [] + for idx in range(replicas): + if process_hostnames is not None: + pod_dns.append(process_hostnames[idx]) + else: + pod_dns.append(pod_fqdn_fstring.format(idx)) + pods.append(f"{resource_name}-{idx}") + + spec["dnsNames"] = pods + pod_dns + if additional_domains is not None: + spec["dnsNames"] += additional_domains + + cert_secret_name = generate_cert( + namespace=namespace, + pod=pods, + dns=pod_dns, + issuer=issuer, + spec=spec, + secret_name=secret_name, + secret_backend=secret_backend, + vault_subpath=vault_subpath, + common_name=common_name, + ) + return cert_secret_name + + +def create_ops_manager_tls_certs( + issuer: str, + namespace: str, + om_name: str, + secret_name: Optional[str] = None, + secret_backend: Optional[str] = None, + additional_domains: Optional[List[str]] = None, + api_client: Optional[kubernetes.client.ApiClient] = None, +) -> str: + certs_secret_name = "certs-for-ops-manager" + + if secret_name is not None: + certs_secret_name = secret_name + + domain = f"{om_name}-svc.{namespace}.svc.cluster.local" + hostnames = [domain] + if additional_domains: + hostnames += additional_domains + + spec = {"dnsNames": hostnames} + + return generate_cert( + namespace=namespace, + pod="foo", + dns="", + issuer=issuer, + spec=spec, + secret_name=certs_secret_name, + secret_backend=secret_backend, + vault_subpath="opsmanager", + api_client=api_client, + ) + + +def create_vault_certs(namespace: str, issuer: str, vault_namespace: str, vault_name: str, secret_name: str): + cert = Certificate(namespace=namespace, name=secret_name) + + cert["spec"] = { + "commonName": f"{vault_name}", + "ipAddresses": [ + "127.0.0.1", + ], + "dnsNames": [ + f"{vault_name}", + f"{vault_name}.{vault_namespace}", + f"{vault_name}.{vault_namespace}.svc", + f"{vault_name}.{vault_namespace}.svc.cluster.local", + ], + "secretName": secret_name, + "issuerRef": {"name": issuer}, + "duration": "240h", + "renewBefore": "120h", + "usages": ["server auth", "digital signature", "key encipherment"], + } + + cert.create().block_until_ready() + data = read_secret(namespace, secret_name) + + # When re-running locally, we need to delete the secrets, if it exists + try: + delete_secret(vault_namespace, secret_name) + except ApiException: + pass + create_secret(vault_namespace, secret_name, data) + return secret_name + + +def create_mongodb_tls_certs( + issuer: str, + namespace: str, + resource_name: str, + bundle_secret_name: str, + replicas: int = 3, + service_name: str = None, + spec: Optional[Dict] = None, + additional_domains: Optional[List[str]] = None, + secret_backend: Optional[str] = None, + vault_subpath: Optional[str] = None, + process_hostnames: Optional[List[str]] = None, +) -> str: + """ + :param process_hostnames: set for TLS certificate to contain only given domains + """ + cert_and_pod_names = create_tls_certs( + issuer=issuer, + namespace=namespace, + resource_name=resource_name, + replicas=replicas, + service_name=service_name, + spec=spec, + additional_domains=additional_domains, + secret_name=bundle_secret_name, + secret_backend=secret_backend, + vault_subpath=vault_subpath, + process_hostnames=process_hostnames, + ) + + return cert_and_pod_names + + +def multi_cluster_service_fqdns( + resource_name: str, + namespace: str, + external_domain: str, + cluster_index: int, + replicas: int, +) -> List[str]: + service_fqdns = [] + + for n in range(replicas): + if external_domain is None: + service_fqdns.append(f"{resource_name}-{cluster_index}-{n}-svc.{namespace}.svc.cluster.local") + else: + service_fqdns.append(f"{resource_name}-{cluster_index}-{n}.{external_domain}") + + return service_fqdns + + +def multi_cluster_external_service_fqdns( + resource_name: str, namespace: str, cluster_index: int, replicas: int +) -> List[str]: + service_fqdns = [] + + for n in range(replicas): + service_fqdns.append(f"{resource_name}-{cluster_index}-{n}-svc-external.{namespace}.svc.cluster.local") + + return service_fqdns + + +def create_multi_cluster_tls_certs( + multi_cluster_issuer: str, + secret_name: str, + central_cluster_client: kubernetes.client.ApiClient, + member_clients: List[MultiClusterClient], + mongodb_multi: MongoDBMulti, + secret_backend: Optional[str] = None, + additional_domains: Optional[List[str]] = None, + service_fqdns: Optional[List[str]] = None, + clusterwide: bool = False, + spec: Optional[dict] = None, +) -> str: + if service_fqdns is None: + service_fqdns = [f"{mongodb_multi.name}-svc.{mongodb_multi.namespace}.svc.cluster.local"] + + for client in member_clients: + cluster_spec = mongodb_multi.get_item_spec(client.cluster_name) + try: + external_domain = cluster_spec["externalAccess"]["externalDomain"] + except KeyError: + external_domain = None + service_fqdns.extend( + multi_cluster_service_fqdns( + mongodb_multi.name, + mongodb_multi.namespace, + external_domain, + client.cluster_index, + cluster_spec["members"], + ) + ) + + generate_cert( + namespace=mongodb_multi.namespace, + pod="tmp", + dns="", + issuer=multi_cluster_issuer, + additional_domains=additional_domains, + multi_cluster_mode=True, + api_client=central_cluster_client, + secret_backend=secret_backend, + secret_name=secret_name, + vault_subpath="database", + dns_list=service_fqdns, + spec=spec, + clusterwide=clusterwide, + ) + + return secret_name + + +def create_multi_cluster_agent_certs( + multi_cluster_issuer: str, + secret_name: str, + central_cluster_client: kubernetes.client.ApiClient, + mongodb_multi: MongoDBMulti, + secret_backend: Optional[str] = None, +) -> str: + agents = ["mms-automation-agent"] + subject = copy.deepcopy(SUBJECT) + subject["organizationalUnits"] = [mongodb_multi.namespace] + + spec = { + "subject": subject, + "usages": ["client auth"], + } + spec["dnsNames"] = agents + spec["commonName"] = "mms-automation-agent" + return generate_cert( + namespace=mongodb_multi.namespace, + pod="tmp", + dns="", + issuer=multi_cluster_issuer, + spec=spec, + multi_cluster_mode=True, + api_client=central_cluster_client, + secret_backend=secret_backend, + secret_name=secret_name, + vault_subpath="database", + ) + + +def create_multi_cluster_x509_agent_certs( + multi_cluster_issuer: str, + secret_name: str, + central_cluster_client: kubernetes.client.ApiClient, + mongodb_multi: MongoDBMulti, + secret_backend: Optional[str] = None, +) -> str: + spec = get_agent_x509_subject(mongodb_multi.namespace) + + return generate_cert( + namespace=mongodb_multi.namespace, + pod="tmp", + dns="", + issuer=multi_cluster_issuer, + spec=spec, + multi_cluster_mode=True, + api_client=central_cluster_client, + secret_backend=secret_backend, + secret_name=secret_name, + vault_subpath="database", + ) + + +def create_multi_cluster_mongodb_tls_certs( + multi_cluster_issuer: str, + bundle_secret_name: str, + member_cluster_clients: List[MultiClusterClient], + central_cluster_client: kubernetes.client.ApiClient, + mongodb_multi: MongoDBMulti, + additional_domains: Optional[List[str]] = None, + service_fqdns: Optional[List[str]] = None, + clusterwide: bool = False, +) -> str: + # create the "source-of-truth" tls cert in central cluster + create_multi_cluster_tls_certs( + multi_cluster_issuer=multi_cluster_issuer, + central_cluster_client=central_cluster_client, + member_clients=member_cluster_clients, + secret_name=bundle_secret_name, + mongodb_multi=mongodb_multi, + additional_domains=additional_domains, + service_fqdns=service_fqdns, + clusterwide=clusterwide, + ) + + return bundle_secret_name + + +def create_multi_cluster_mongodb_x509_tls_certs( + multi_cluster_issuer: str, + bundle_secret_name: str, + member_cluster_clients: List[MultiClusterClient], + central_cluster_client: kubernetes.client.ApiClient, + mongodb_multi: MongoDBMulti, + additional_domains: Optional[List[str]] = None, + service_fqdns: Optional[List[str]] = None, + clusterwide: bool = False, +) -> str: + spec = get_mongodb_x509_subject(mongodb_multi.namespace) + + # create the "source-of-truth" tls cert in central cluster + create_multi_cluster_tls_certs( + multi_cluster_issuer=multi_cluster_issuer, + central_cluster_client=central_cluster_client, + member_clients=member_cluster_clients, + secret_name=bundle_secret_name, + mongodb_multi=mongodb_multi, + additional_domains=additional_domains, + service_fqdns=service_fqdns, + clusterwide=clusterwide, + spec=spec, + ) + + return bundle_secret_name + + +def create_x509_mongodb_tls_certs( + issuer: str, + namespace: str, + resource_name: str, + bundle_secret_name: str, + replicas: int = 3, + service_name: str = None, + additional_domains: Optional[List[str]] = None, + secret_backend: Optional[str] = None, + vault_subpath: Optional[str] = None, +) -> str: + spec = get_mongodb_x509_subject(namespace) + + return create_mongodb_tls_certs( + issuer=issuer, + namespace=namespace, + resource_name=resource_name, + bundle_secret_name=bundle_secret_name, + replicas=replicas, + service_name=service_name, + spec=spec, + additional_domains=additional_domains, + secret_backend=secret_backend, + vault_subpath=vault_subpath, + ) + + +def get_mongodb_x509_subject(namespace): + """ + x509 certificates need a subject, more here: https://wiki.corp.mongodb.com/display/MMS/E2E+Tests+Notes + """ + subject = { + "countries": ["US"], + "provinces": ["NY"], + "localities": ["NY"], + "organizations": ["cluster.local-server"], + "organizationalUnits": [namespace], + } + spec = { + "subject": subject, + "usages": [ + "digital signature", + "key encipherment", + "client auth", + "server auth", + ], + } + return spec + + +def get_agent_x509_subject(namespace): + """ + x509 certificates need a subject, more here: https://wiki.corp.mongodb.com/display/MMS/E2E+Tests+Notes + """ + agents = ["automation", "monitoring", "backup"] + subject = { + "countries": ["US"], + "provinces": ["NY"], + "localities": ["NY"], + "organizations": ["cluster.local-agent"], + "organizationalUnits": [namespace], + } + spec = { + "subject": subject, + "usages": ["digital signature", "key encipherment", "client auth"], + "dnsNames": agents, + "commonName": "mms-automation-agent", + } + return spec + + +def create_agent_tls_certs( + issuer: str, + namespace: str, + name: str, + secret_prefix: Optional[str] = None, + secret_backend: Optional[str] = None, +) -> str: + agents = ["mms-automation-agent"] + subject = copy.deepcopy(SUBJECT) + subject["organizationalUnits"] = [namespace] + + spec = { + "subject": subject, + "usages": ["client auth"], + } + spec["dnsNames"] = agents + spec["commonName"] = "mms-automation-agent" + secret_name = "agent-certs" if secret_prefix is None else f"{secret_prefix}-{name}-agent-certs" + secret = generate_cert( + namespace=namespace, + pod=[], + dns=[], + issuer=issuer, + spec=spec, + secret_name=secret_name, + secret_backend=secret_backend, + vault_subpath="database", + ) + return secret + + +def create_sharded_cluster_certs( + namespace: str, + resource_name: str, + shards: int, + mongos_per_shard: int, + config_servers: int, + mongos: int, + internal_auth: bool = False, + x509_certs: bool = False, + additional_domains: Optional[List[str]] = None, + secret_prefix: Optional[str] = None, + secret_backend: Optional[str] = None, +): + cert_generation_func = create_mongodb_tls_certs + if x509_certs: + cert_generation_func = create_x509_mongodb_tls_certs + + secret_type = "kubernetes.io/tls" + for i in range(shards): + additional_domains_for_shard = None + if additional_domains is not None: + additional_domains_for_shard = [] + for domain in additional_domains: + for j in range(mongos_per_shard): + additional_domains_for_shard.append(f"{resource_name}-{i}-{j}.{domain}") + + secret_name = f"{resource_name}-{i}-cert" + if secret_prefix is not None: + secret_name = secret_prefix + secret_name + cert_generation_func( + issuer=ISSUER_CA_NAME, + namespace=namespace, + resource_name=f"{resource_name}-{i}", + bundle_secret_name=secret_name, + replicas=mongos_per_shard, + service_name=resource_name + "-sh", + additional_domains=additional_domains_for_shard, + secret_backend=secret_backend, + ) + if internal_auth: + cert_generation_func( + issuer=ISSUER_CA_NAME, + namespace=namespace, + resource_name=f"{resource_name}-{i}-clusterfile", + bundle_secret_name=f"{resource_name}-{i}-clusterfile", + replicas=mongos_per_shard, + service_name=resource_name + "-sh", + additional_domains=additional_domains_for_shard, + secret_backend=secret_backend, + ) + + additional_domains_for_config = None + if additional_domains is not None: + additional_domains_for_config = [] + for domain in additional_domains: + for j in range(config_servers): + additional_domains_for_config.append(f"{resource_name}-config-{j}.{domain}") + + secret_name = f"{resource_name}-config-cert" + if secret_prefix is not None: + secret_name = secret_prefix + secret_name + cert_generation_func( + issuer=ISSUER_CA_NAME, + namespace=namespace, + resource_name=resource_name + "-config", + bundle_secret_name=secret_name, + replicas=config_servers, + service_name=resource_name + "-cs", + additional_domains=additional_domains_for_config, + secret_backend=secret_backend, + ) + if internal_auth: + cert_generation_func( + issuer=ISSUER_CA_NAME, + namespace=namespace, + resource_name=f"{resource_name}-config-clusterfile", + bundle_secret_name=f"{resource_name}-config-clusterfile", + replicas=mongos_per_shard, + service_name=resource_name + "-sh", + additional_domains=additional_domains_for_shard, + secret_backend=secret_backend, + ) + + additional_domains_for_mongos = None + if additional_domains is not None: + additional_domains_for_mongos = [] + for domain in additional_domains: + for j in range(mongos): + additional_domains_for_mongos.append(f"{resource_name}-mongos-{j}.{domain}") + + secret_name = f"{resource_name}-mongos-cert" + if secret_prefix is not None: + secret_name = secret_prefix + secret_name + cert_generation_func( + issuer=ISSUER_CA_NAME, + namespace=namespace, + resource_name=resource_name + "-mongos", + bundle_secret_name=secret_name, + service_name=resource_name + "-svc", + replicas=mongos, + additional_domains=additional_domains_for_mongos, + secret_backend=secret_backend, + ) + + if internal_auth: + cert_generation_func( + issuer=ISSUER_CA_NAME, + namespace=namespace, + resource_name=f"{resource_name}-mongos-clusterfile", + bundle_secret_name=f"{resource_name}-mongos-clusterfile", + replicas=mongos_per_shard, + service_name=resource_name + "-sh", + additional_domains=additional_domains_for_shard, + secret_backend=secret_backend, + ) + + +def create_x509_agent_tls_certs(issuer: str, namespace: str, name: str, secret_backend: Optional[str] = None) -> str: + spec = get_agent_x509_subject(namespace) + return generate_cert( + namespace=namespace, + pod=[], + dns=[], + issuer=issuer, + spec=spec, + secret_name="agent-certs", + secret_backend=secret_backend, + vault_subpath="database", + ) + + +def approve_certificate(name: str) -> None: + """Approves the CertificateSigningRequest with the provided name""" + body = client.CertificatesV1beta1Api().read_certificate_signing_request_status(name) + conditions = client.V1beta1CertificateSigningRequestCondition( + last_update_time=datetime.now(timezone.utc).astimezone(), + message="This certificate was approved by E2E testing framework", + reason="E2ETestingFramework", + type="Approved", + ) + + body.status.conditions = [conditions] + client.CertificatesV1beta1Api().replace_certificate_signing_request_approval(name, body) + + +def create_x509_user_cert(issuer: str, namespace: str, path: str): + user_name = "x509-testing-user" + + spec = { + "usages": ["digital signature", "key encipherment", "client auth"], + "commonName": user_name, + } + secret = generate_cert( + namespace=namespace, + pod=user_name, + dns=user_name, + issuer=issuer, + spec=spec, + secret_name="mongodbuser", + ) + cert = KubernetesTester.read_secret(namespace, secret) + with open(path, mode="w") as f: + f.write(cert["tls.key"]) + f.write(cert["tls.crt"]) + f.flush() + + +def create_multi_cluster_x509_user_cert( + multi_cluster_issuer: str, + namespace: str, + central_cluster_client: kubernetes.client.ApiClient, + path: str, +): + user_name = "x509-testing-user" + spec = { + "usages": ["digital signature", "key encipherment", "client auth"], + "commonName": user_name, + } + secret = generate_cert( + namespace=namespace, + pod="tmp", + dns=user_name, + issuer=multi_cluster_issuer, + api_client=central_cluster_client, + spec=spec, + multi_cluster_mode=True, + secret_name="mongodbuser", + ) + cert = read_secret(namespace, secret, api_client=central_cluster_client) + with open(path, mode="w") as f: + f.write(cert["tls.key"]) + f.write(cert["tls.crt"]) + f.flush() + + +def yield_existing_csrs(csr_names: List[str], timeout: int = 300) -> Generator[str, None, None]: + """Returns certificate names as they start appearing in the Kubernetes API.""" + csr_names = csr_names.copy() + total_csrs = len(csr_names) + seen_csrs = 0 + stop_time = time.time() + timeout + + while len(csr_names) > 0 and time.time() < stop_time: + csr = random.choice(csr_names) + try: + client.CertificatesV1beta1Api().read_certificate_signing_request_status(csr) + except ApiException: + time.sleep(3) + continue + + seen_csrs += 1 + csr_names.remove(csr) + yield csr + + if len(csr_names) == 0: + # All the certificates have been "consumed" and yielded back to the user. + return + + # we didn't find all of the expected csrs after the timeout period + raise AssertionError( + f"Expected to find {total_csrs} csrs, but only found {seen_csrs} after {timeout} seconds. Expected csrs {csr_names}" + ) diff --git a/docker/mongodb-enterprise-tests/kubetester/create_or_replace_from_yaml.py b/docker/mongodb-enterprise-tests/kubetester/create_or_replace_from_yaml.py new file mode 100644 index 000000000..824013212 --- /dev/null +++ b/docker/mongodb-enterprise-tests/kubetester/create_or_replace_from_yaml.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import re +from os import path + +import yaml +from kubernetes import client +from kubernetes.client import ApiClient +from kubernetes.utils.create_from_yaml import create_from_yaml_single_item + +""" +This is a modification of 'create_from_yaml.py' from python kubernetes client library +It allows to mimic the 'kubectl apply' operation on yaml file. It performs either create or +patch on each individual object. +""" + + +def create_or_replace_from_yaml(k8s_client: ApiClient, yaml_file: str, namespace: str = "default", **kwargs): + with open(path.abspath(yaml_file)) as f: + yml_document_all = yaml.safe_load_all(f) + # Load all documents from a single YAML file + for yml_document in yml_document_all: + create_or_patch_from_dict(k8s_client, yml_document, namespace=namespace, **kwargs) + + +def create_or_patch_from_dict(k8s_client, yml_document, namespace="default", **kwargs): + # If it is a list type, will need to iterate its items + if "List" in yml_document["kind"]: + # Could be "List" or "Pod/Service/...List" + # This is a list type. iterate within its items + kind = yml_document["kind"].replace("List", "") + for yml_object in yml_document["items"]: + # Mitigate cases when server returns a xxxList object + # See kubernetes-client/python#586 + if kind != "": + yml_object["apiVersion"] = yml_document["apiVersion"] + yml_object["kind"] = kind + create_or_replace_from_yaml_single_item(k8s_client, yml_object, namespace, **kwargs) + else: + # Try to create the object or patch if it already exists + create_or_replace_from_yaml_single_item(k8s_client, yml_document, namespace, **kwargs) + + +def create_or_replace_from_yaml_single_item(k8s_client, yml_object, namespace="default", **kwargs): + try: + create_from_yaml_single_item(k8s_client, yml_object, verbose=False, namespace=namespace, **kwargs) + except client.rest.ApiException: + patch_from_yaml_single_item(k8s_client, yml_object, namespace, **kwargs) + except ValueError: + if get_kind(yml_object) == "custom_resource_definition": + # TODO unfortunately CRD creation results in error before 1.12 python lib and 1.16 K8s + # https://github.com/kubernetes-client/python/issues/1022 + pass + + +def patch_from_yaml_single_item(k8s_client, yml_object, namespace="default", **kwargs): + k8s_api = get_k8s_api(k8s_client, yml_object) + kind = get_kind(yml_object) + # Decide which namespace we are going to put the object in, + # if any + if "namespace" in yml_object["metadata"]: + namespace = yml_object["metadata"]["namespace"] + name = yml_object["metadata"]["name"] + + method = "patch" + if kind == "custom_resource_definition": + # fetching the old CRD to make the replace working (has conflict resolution based on 'resourceVersion') + # TODO this is prone to race conditions - we need to either loop or use patch with json merge + # see https://github.com/helm/helm/pull/6092/files#diff-a483d6c0863082c3df21f4aad513afe2R663 + resource = client.ApiextensionsV1Api().read_custom_resource_definition(name) + + yml_object["metadata"]["resourceVersion"] = resource.metadata.resource_version + method = "replace" + + namespaced = hasattr(k8s_api, "{}_namespaced_{}".format("create", kind)) + url_path = get_url_path(namespaced, method) + + if namespaced: + # Note that patch the deployment can result in + # "Invalid value: \"\": may not be specified when `value` is not empty","field":"spec.template.spec.containers[0].env[1].valueFrom" + # (https://github.com/kubernetes/kubernetes/issues/46861) if "kubectl apply" was used initially to create the object + # This is safe though if the object was created using python API + getattr(k8s_api, url_path.format(kind))(body=yml_object, namespace=namespace, name=name, **kwargs) + else: + # "patch" endpoints require to specify 'name' attribute + getattr(k8s_api, url_path.format(kind))(body=yml_object, name=name, **kwargs) + + +def get_kind(yml_object): + # Replace CamelCased action_type into snake_case + kind = yml_object["kind"] + kind = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", kind) + kind = re.sub("([a-z0-9])([A-Z])", r"\1_\2", kind).lower() + return kind + + +def get_k8s_api(k8s_client, yml_object): + group, _, version = yml_object["apiVersion"].partition("/") + if version == "": + version = group + group = "core" + # Take care for the case e.g. api_type is "apiextensions.k8s.io" + # Only replace the last instance + group = "".join(group.rsplit(".k8s.io", 1)) + # convert group name from DNS subdomain format to + # python class name convention + group = "".join(word.capitalize() for word in group.split(".")) + fcn_to_call = "{0}{1}Api".format(group, version.capitalize()) + k8s_api = getattr(client, fcn_to_call)(k8s_client) + return k8s_api + + +def get_url_path(namespaced, method: str): + if namespaced: + url_path = method + "_namespaced_{}" + else: + url_path = method + "_{}" + return url_path diff --git a/docker/mongodb-enterprise-tests/kubetester/crypto.py b/docker/mongodb-enterprise-tests/kubetester/crypto.py new file mode 100644 index 000000000..558354cef --- /dev/null +++ b/docker/mongodb-enterprise-tests/kubetester/crypto.py @@ -0,0 +1,74 @@ +from kubernetes import client + +from cryptography import x509 +from cryptography.x509.oid import NameOID +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.asymmetric import rsa + +import base64 +import time + +from typing import List, Optional + + +def generate_csr(namespace: str, host: str, servicename: str): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048, backend=default_backend()) + + csr = ( + x509.CertificateSigningRequestBuilder() + .subject_name( + x509.Name( + [ + x509.NameAttribute(NameOID.COUNTRY_NAME, "US"), + x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "New York"), + x509.NameAttribute(NameOID.LOCALITY_NAME, "New York"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Mongodb"), + x509.NameAttribute(NameOID.COMMON_NAME, host), + ] + ) + ) + .add_extension( + x509.SubjectAlternativeName( + [ + x509.DNSName(f"{host}."), + x509.DNSName(f"{host}.{servicename}.{namespace}.svc.cluster.local"), + x509.DNSName(f"{servicename}.{namespace}.svc.cluster.local"), + ] + ), + critical=False, + ) + .sign(key, hashes.SHA256(), default_backend()) + ) + + return ( + csr.public_bytes(serialization.Encoding.PEM), + key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ), + ) + + +def get_pem_certificate(name: str) -> Optional[str]: + body = client.CertificatesV1beta1Api().read_certificate_signing_request_status(name) + if body.status.certificate is None: + return None + return base64.b64decode(body.status.certificate) + + +def wait_for_certs_to_be_issued(certificates: List[str]) -> None: + un_issued_certs = set(certificates) + while un_issued_certs: + issued_certs = set() + to_wait = False + for cert in un_issued_certs: + if get_pem_certificate(cert): + issued_certs.add(cert) + else: + print(f"waiting for certificate {cert} to be issued") + to_wait = True + un_issued_certs -= issued_certs + if to_wait: + time.sleep(1) diff --git a/docker/mongodb-enterprise-tests/kubetester/custom_podspec.py b/docker/mongodb-enterprise-tests/kubetester/custom_podspec.py new file mode 100644 index 000000000..6c1b33dbe --- /dev/null +++ b/docker/mongodb-enterprise-tests/kubetester/custom_podspec.py @@ -0,0 +1,42 @@ +from typing import List, Optional + + +def assert_stateful_set_podspec( + pod_template_spec, + weight: int = 0, + topology_key: str = "", + grace_period_seconds: int = 0, + containers_spec: Optional[List] = None, +) -> None: + assert pod_template_spec.termination_grace_period_seconds == grace_period_seconds + assert ( + pod_template_spec.affinity.pod_anti_affinity.preferred_during_scheduling_ignored_during_execution[ + 0 + ].weight + == weight + ) + assert ( + pod_template_spec.affinity.pod_anti_affinity.preferred_during_scheduling_ignored_during_execution[ + 0 + ].pod_affinity_term.topology_key + == topology_key + ) + if containers_spec is None: + containers_spec = [] + for i, expected_spec in enumerate(containers_spec): + spec = pod_template_spec.containers[i].to_dict() + # compare only the expected keys + for k in expected_spec: + if k == "volume_mounts": + assert_volume_mounts_are_equal(expected_spec[k], spec[k]) + else: + assert expected_spec[k] == spec[k] + + +def assert_volume_mounts_are_equal(volume_mounts_1, volume_mounts_2): + + sorted_vols_1 = sorted(volume_mounts_1, key=lambda m: (m["name"], m["mount_path"])) + sorted_vols_2 = sorted(volume_mounts_2, key=lambda m: (m["name"], m["mount_path"])) + + for (vol1, vol2) in zip(sorted_vols_1, sorted_vols_2): + assert vol1 == vol2 diff --git a/docker/mongodb-enterprise-tests/kubetester/git.py b/docker/mongodb-enterprise-tests/kubetester/git.py new file mode 100644 index 000000000..e385e33b8 --- /dev/null +++ b/docker/mongodb-enterprise-tests/kubetester/git.py @@ -0,0 +1,6 @@ +from git import Repo + + +def clone_and_checkout(url: str, fs_path: str, branch_name: str): + repo = Repo.clone_from(url, fs_path) + repo.git.checkout(branch_name) diff --git a/docker/mongodb-enterprise-tests/kubetester/helm.py b/docker/mongodb-enterprise-tests/kubetester/helm.py new file mode 100644 index 000000000..3a1b65c11 --- /dev/null +++ b/docker/mongodb-enterprise-tests/kubetester/helm.py @@ -0,0 +1,195 @@ +import logging +import os +import re +import subprocess +import uuid +from typing import Dict, List, Optional, Tuple + + +def helm_template( + helm_args: Dict, + helm_chart_path: Optional[str] = "helm_chart", + templates: Optional[str] = None, + helm_options: Optional[List[str]] = None, +) -> str: + """generates yaml file using Helm and returns its name. Provide 'templates' if you need to run + a specific template from the helm chart""" + command_args = _create_helm_args(helm_args, helm_options) + + if templates is not None: + command_args.append("--show-only") + command_args.append(templates) + + args = ("helm", "template", *(command_args), _helm_chart_dir(helm_chart_path)) + logging.info(args) + + yaml_file_name = "{}.yaml".format(str(uuid.uuid4())) + with open(yaml_file_name, "w") as output: + process_run_and_check(" ".join(args), stdout=output, check=True, shell=True) + + return yaml_file_name + + +def helm_install( + name: str, + namespace: str, + helm_args: Dict, + helm_chart_path: Optional[str] = "helm_chart", + helm_options: Optional[List[str]] = None, +): + command_args = _create_helm_args(helm_args, helm_options) + args = ( + "helm", + "upgrade", + "--install", + f"--namespace={namespace}", + *(command_args), + name, + _helm_chart_dir(helm_chart_path), + ) + logging.info(args) + + process_run_and_check(" ".join(args), check=True, capture_output=True, shell=True) + + +def helm_install_from_chart( + namespace: str, + release: str, + chart: str, + version: str = "", + custom_repo: Tuple[str, str] = ("stable", "https://charts.helm.sh/stable"), + helm_args: Optional[Dict[str, str]] = None, + override_path: Optional[str] = None, +): + """Installs a helm chart from a repo. It can accept a new custom_repo to add before the + chart is installed. Also, `helm_args` accepts a dictionary that will be passed as --set + arguments to `helm install`. + + Some charts are clusterwide (like CertManager), and simultaneous installation can + fail. This function tolerates errors when installing the Chart if `stderr` of the + Helm process has the "release: already exists" string on it. + """ + + args = [ + "helm", + "upgrade", + "--install", + release, + f"--namespace={namespace}", + chart, + ] + + if override_path is not None: + args.extend(["-f", f"{override_path}"]) + + if version != "": + args.append("--version=" + version) + + if helm_args is not None: + args += _create_helm_args(helm_args) + + helm_repo_add(custom_repo[0], custom_repo[1]) + + try: + # In shared clusters (Kops: e2e) multiple simultaneous cert-manager + # installations will fail. We tolerate errors in those cases. + process_run_and_check(args, capture_output=True) + except subprocess.CalledProcessError as exc: + stderr = exc.stderr.decode("utf-8") + if ( + "release: already exists" in stderr + or "Error: UPGRADE FAILED: another operation" in stderr + ): + logging.info(f"Helm chart '{chart}' already installed in cluster.") + else: + raise + + +def helm_repo_add(repo_name: str, url: str): + """ + Adds a new repo to Helm. + """ + helm_repo_add = f"helm repo add {repo_name} {url}".split() + logging.info(helm_repo_add) + process_run_and_check(helm_repo_add, capture_output=True) + + +def process_run_and_check(args, **kwargs): + try: + completed_process = subprocess.run(args, **kwargs) + completed_process.check_returncode() + except subprocess.CalledProcessError as exc: + stdout = exc.stdout.decode("utf-8") + stderr = exc.stderr.decode("utf-8") + logging.info(exc.output) + logging.info(stdout) + logging.info(stderr) + raise + + +def helm_upgrade( + name: str, + namespace: str, + helm_args: Dict, + helm_chart_path: Optional[str] = "helm_chart", + helm_options: Optional[List[str]] = None, + helm_override_path: Optional[bool] = False, +): + command_args = _create_helm_args(helm_args, helm_options) + args = [ + "helm", + "upgrade", + "--install", + f"--namespace={namespace}", + *command_args, + name, + ] + if helm_override_path: + args.append(helm_chart_path) + else: + args.append(_helm_chart_dir(helm_chart_path)) + + logging.info(args) + + process_run_and_check(" ".join(args), check=True, capture_output=True, shell=True) + + +def helm_uninstall(name): + args = ("helm", "uninstall", name) + logging.info(args) + process_run_and_check(" ".join(args), check=True, capture_output=True, shell=True) + + +def _create_helm_args( + helm_args: Dict[str, str], helm_options: Optional[List[str]] = None +) -> List[str]: + command_args = [] + for key, value in helm_args.items(): + command_args.append("--set") + + if "," in value: + # helm lists are defined with {}, hence matching this means we don't have to escape. + if not re.match("^{.+}$", value): + # Commas in values, but no lists, should be escaped + value = value.replace(",", "\,") + + # and when commas are present, we should quote "key=value" + key = '"' + key + value = value + '"' + + command_args.append("{}={}".format(key, value)) + + if "useRunningOperator" in helm_args: + logging.info("Operator will not be installed this time, passing --dry-run") + command_args.append("--dry-run") + + command_args.append("--create-namespace") + + if helm_options: + command_args.extend(helm_options) + + return command_args + + +def _helm_chart_dir(default: Optional[str] = "helm_chart") -> str: + return os.environ.get("HELM_CHART_DIR", default) diff --git a/docker/mongodb-enterprise-tests/kubetester/http.py b/docker/mongodb-enterprise-tests/kubetester/http.py new file mode 100644 index 000000000..7cf105e1b --- /dev/null +++ b/docker/mongodb-enterprise-tests/kubetester/http.py @@ -0,0 +1,44 @@ +from typing import Tuple + +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + + +def get_retriable_session(proto: str, tls_verify: bool) -> requests.Session: + """ + Returns a request Session object with a retry mechanism. + + This is required to overcome a DNS resolution problem that we have + experienced in the Evergreen hosts. This can also probably alleviate + problems arising from request throttling. + """ + + s = requests.Session() + + s.verify = tls_verify + retries = Retry( + total=5, + backoff_factor=2, + ) + s.mount(proto + "://", HTTPAdapter(max_retries=retries)) + + return s + + +def get_retriable_https_session(*, tls_verify: bool) -> requests.Session: + return get_retriable_session("https", tls_verify) + + +def https_endpoint_is_reachable( + url: str, auth: Tuple[str], *, tls_verify: bool +) -> bool: + """ + Checks that `url` is reachable, using `auth` basic credentials. + """ + return ( + get_retriable_https_session(tls_verify=tls_verify) + .get(url, auth=auth) + .status_code + == 200 + ) diff --git a/docker/mongodb-enterprise-tests/kubetester/kmip.py b/docker/mongodb-enterprise-tests/kubetester/kmip.py new file mode 100644 index 000000000..1d9cf9147 --- /dev/null +++ b/docker/mongodb-enterprise-tests/kubetester/kmip.py @@ -0,0 +1,200 @@ +# ---------------------------------------------------------------------------- +# This file contains the implementation of the KMIP Server (PyKMIP) deployment +# +# The deployment has been outlined in the outdated Enterprise Kubernetes Operator +# guide, that might be found here: +# https://docs.google.com/document/d/12Y5h7XDFedcgpSIWRxMgcjZClL6kZdIwdxPRotkuKck/edit# +# ----------------------------------------------------------- + +from typing import Optional, Dict +from kubetester import ( + create_secret, + read_secret, + read_configmap, + create_service, + create_statefulset, + create_configmap, +) +from kubetester.kubetester import KubernetesTester +from kubetester.certs import create_tls_certs +from kubernetes import client + + +class KMIPDeployment(object): + """ + A KMIP Server deployment class. Deploys PyKMIP in the cluster. + """ + + def __init__(self, namespace, issuer, root_cert_secret, ca_configmap: str): + self.namespace = namespace + self.issuer = issuer + self.root_cert_secret = root_cert_secret + self.ca_configmap = ca_configmap + self.statefulset_name = "kmip" + self.labels = { + "app": "kmip", + } + + def deploy(self): + """ + Deploys a PyKMIP Server and returns the name of the deployed StatefulSet. + """ + service_name = f"{self.statefulset_name}-svc" + + cert_secret_name = self._create_tls_certs_kmip( + self.issuer, + self.namespace, + self.statefulset_name, + "kmip-certs", + 1, + service_name, + ) + + create_service( + self.namespace, + service_name, + cluster_ip=None, + ports=[client.V1ServicePort(name="kmip", port=5696)], + selector=self.labels, + ) + + self._create_kmip_config_map(self.namespace, "kmip-config", self._default_configuration()) + + create_statefulset( + self.namespace, + self.statefulset_name, + service_name, + self.labels, + containers=[ + client.V1Container( + # We need this awkward copy step as PyKMIP uses /etc/pykmip as a tmp directory. When booting up + # it stores there some intermediate configuration files. So it must have write access to the whole + # /etc/pykmip directory. Very awkward... + args=[ + "bash", + "-c", + "cp /etc/pykmip-conf/server.conf /etc/pykmip/server.conf & /tmp/configure.sh & mkdir -p /var/log/pykmip & touch /var/log/pykmip/server.log & tail -f /var/log/pykmip/server.log", + ], + name="kmip", + image="beergeek1679/pykmip:0.6.0", + image_pull_policy="IfNotPresent", + ports=[ + client.V1ContainerPort( + container_port=5696, + name="kmip", + ) + ], + volume_mounts=[ + client.V1VolumeMount( + name="certs", + mount_path="/data/pki", + read_only=True, + ), + client.V1VolumeMount( + name="config", + mount_path="/etc/pykmip-conf", + read_only=True, + ), + ], + ) + ], + volumes=[ + client.V1Volume( + name="certs", + secret=client.V1SecretVolumeSource( + secret_name=cert_secret_name, + ), + ), + client.V1Volume( + name="config", + config_map=client.V1ConfigMapVolumeSource(name="kmip-config"), + ), + ], + ) + return self + + def status(self): + return KMIPDeploymentStatus(self) + + def _create_tls_certs_kmip( + self, + issuer: str, + namespace: str, + resource_name: str, + bundle_secret_name: str, + replicas: int = 3, + service_name: str = None, + spec: Optional[Dict] = None, + ) -> str: + ca = read_configmap(namespace, self.ca_configmap) + cert_secret_name = create_tls_certs( + issuer, + namespace, + resource_name, + replicas, + service_name, + spec, + additional_domains=[service_name], + ) + secret = read_secret(namespace, cert_secret_name) + create_secret( + namespace, + bundle_secret_name, + { + "server.key": secret["tls.key"], + "server.cert": secret["tls.crt"], + "ca.cert": ca["ca-pem"], + }, + ) + + return bundle_secret_name + + def _default_configuration(self) -> Dict: + return { + "hostname": "kmip-0", + "port": 5696, + "certificate_path": "/data/pki/server.cert", + "key_path": "/data/pki/server.key", + "ca_path": "/data/pki/ca.cert", + "auth_suite": "TLS1.2", + "enable_tls_client_auth": True, + "policy_path": "/data/policies", + "logging_level": "DEBUG", + "database_path": "/data/db/pykmip.db", + } + + def _create_kmip_config_map(self, namespace: str, name: str, config_dict: Dict) -> None: + """ + _create_configuration_config_map converts a dictionary of options into the server.conf + file that the kmip server uses to start. + """ + equals_separated = [k + "=" + str(v) for (k, v) in config_dict.items()] + config_file_contents = "[server]\n" + "\n".join(equals_separated) + create_configmap( + namespace, + name, + { + "server.conf": config_file_contents, + }, + ) + + +class KMIPDeploymentStatus: + """ + A class designed to check the KMIP Server deployment status. + """ + + def __init__(self, deployment: KMIPDeployment): + self.deployment = deployment + + def assert_is_running(self): + """ + Waits and assert if the KMIP server is running. + :return: raises an error if the server is not running within the timeout. + """ + KubernetesTester.wait_for_condition_stateful_set( + self.deployment.namespace, + self.deployment.statefulset_name, + "status.current_replicas", + 1, + ) diff --git a/docker/mongodb-enterprise-tests/kubetester/kubetester.py b/docker/mongodb-enterprise-tests/kubetester/kubetester.py new file mode 100644 index 000000000..40b816b00 --- /dev/null +++ b/docker/mongodb-enterprise-tests/kubetester/kubetester.py @@ -0,0 +1,1632 @@ +import json +import os +import random +import re +import ssl +import string +import sys +import tarfile +import tempfile +import time +import warnings +from base64 import b64decode, b64encode +from typing import Dict, List, Optional + +import jsonpatch +import kubernetes.client +import pymongo +import pytest +import requests +import semver +import yaml +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +from kubeobject import CustomObject +from kubernetes import client, config +from kubernetes.client.rest import ApiException +from kubernetes.stream import stream +from requests.auth import HTTPDigestAuth + +from kubetester.crypto import wait_for_certs_to_be_issued + +SSL_CA_CERT = "/var/run/secrets/kubernetes.io/serviceaccount/..data/ca.crt" +EXTERNALLY_MANAGED_TAG = "EXTERNALLY_MANAGED_BY_KUBERNETES" +MAX_TAG_LEN = 32 + +DEPRECATION_WARNING = "This feature has been DEPRECATED and should only be used in testing environments." +AGENT_WARNING = "The Operator is generating TLS x509 certificates for agent authentication. " + DEPRECATION_WARNING +MEMBER_AUTH_WARNING = ( + "The Operator is generating TLS x509 certificates for internal cluster authentication. " + DEPRECATION_WARNING +) +SERVER_WARNING = "The Operator is generating TLS certificates for server authentication. " + DEPRECATION_WARNING + +plural_map = { + "MongoDB": "mongodb", + "MongoDBUser": "mongodbusers", + "MongoDBOpsManager": "opsmanagers", + "MongoDBMultiCluster": "mongodbmulticluster", +} + + +def running_locally(): + return os.getenv("POD_NAME", "local") == "local" + + +skip_if_local = pytest.mark.skipif(running_locally(), reason="Only run in Kubernetes cluster") +# time to sleep between retries +SLEEP_TIME = 2 +# no timeout (loop forever) +INFINITY = -1 + + +class KubernetesTester(object): + """ + KubernetesTester is the base class for all python tests. It deliberately doesn't have object state + as it is not expected to have more than one concurrent instance running. All tests must be run in separate + Kubernetes namespaces and use separate Ops Manager groups. + The class provides some common utility methods used by all children and also performs some common + create/update/delete actions for Kubernetes objects based on the docstrings of subclasses + """ + + init = None + group_id = None + + @classmethod + def setup_env(cls): + """Optionally override this in a test instance to create an appropriate test environment.""" + pass + + @classmethod + def teardown_env(cls): + """Optionally override this in a test instance to destroy the test environment.""" + pass + + @classmethod + def create_config_map(cls, namespace, name, data): + """Create a config map in a given namespace with the given name and data.""" + config_map = cls.clients("client").V1ConfigMap( + metadata=cls.clients("client").V1ObjectMeta(name=name), data=data + ) + cls.clients("corev1").create_namespaced_config_map(namespace, config_map) + + @classmethod + def patch_config_map(cls, namespace, name, data): + """Patch a config map in a given namespace with the given name and data.""" + config_map = cls.clients("client").V1ConfigMap(data=data) + cls.clients("corev1").patch_namespaced_config_map(name, namespace, config_map) + + @classmethod + def create_secret(cls, namespace: str, name: str, data: Dict[str, str]): + """ + Deprecated: use kubetester.create_secret instead. + + Create a secret in a given namespace with the given name and data—handles base64 encoding. + """ + secret = cls.clients("client").V1Secret( + metadata=cls.clients("client").V1ObjectMeta(name=name), string_data=data + ) + + try: + cls.clients("corev1").create_namespaced_secret(namespace, secret) + except client.rest.ApiException as e: + if e.status == 409 and running_locally(): + pass + + @classmethod + def update_secret(cls, namespace: str, name: str, data: Dict[str, str]): + """ + Deprecated: use kubetester.update_secret instead. + + Updates a secret in a given namespace with the given name and data—handles base64 encoding. + """ + secret = cls.clients("client").V1Secret( + metadata=cls.clients("client").V1ObjectMeta(name=name), string_data=data + ) + cls.clients("corev1").patch_namespaced_secret(name, namespace, secret) + + @classmethod + def delete_secret(cls, namespace: str, name: str): + """Delete a secret in a given namespace with the given name.""" + cls.clients("corev1").delete_namespaced_secret(name, namespace) + + @classmethod + def delete_csr(cls, name: str): + cls.clients("certificates").delete_certificate_signing_request(name) + + @classmethod + def read_secret(cls, namespace: str, name: str) -> Dict[str, str]: + """ + Deprecated: use kubetester.read_secret instead. + """ + data = cls.clients("corev1").read_namespaced_secret(name, namespace).data + return decode_secret(data=data) + + @classmethod + def decode_secret(cls, data: Dict[str, str]) -> Dict[str, str]: + return {k: b64decode(v).decode("utf-8") for (k, v) in data.items()} + + @classmethod + def read_configmap(cls, namespace: str, name: str, api_client: Optional[client.ApiClient] = None) -> Dict[str, str]: + """ + Deprecated: use kubetester.create_or_update_configmap instead. + """ + corev1 = cls.clients("corev1") + if api_client is not None: + corev1 = client.CoreV1Api(api_client=api_client) + + return corev1.read_namespaced_config_map(name, namespace).data + + @classmethod + def read_pod(cls, namespace: str, name: str) -> Dict[str, str]: + """Reads a ConfigMap and returns its contents""" + return cls.clients("corev1").read_namespaced_pod(name, namespace) + + @classmethod + def read_pod_logs(cls, namespace: str, name: str) -> str: + return cls.clients("corev1").read_namespaced_pod_log(name=name, namespace=namespace) + + @classmethod + def read_operator_pod(cls, namespace: str) -> Dict[str, str]: + label_selector = "app.kubernetes.io/name=mongodb-enterprise-operator" + return cls.read_pod_labels(namespace, label_selector).items[0] + + @classmethod + def read_pod_labels(cls, namespace: str, label_selector: Optional[str] = None) -> Dict[str, str]: + """Reads a Pod by labels.""" + return cls.clients("corev1").list_namespaced_pod(namespace=namespace, label_selector=label_selector) + + @classmethod + def update_configmap( + cls, + namespace: str, + name: str, + data: Dict[str, str], + api_client: Optional[client.ApiClient] = None, + ): + """Updates a ConfigMap in a given namespace with the given name and data—handles base64 encoding.""" + configmap = cls.clients("client", api_client=api_client).V1ConfigMap( + metadata=cls.clients("client", api_client=api_client).V1ObjectMeta(name=name), + data=data, + ) + cls.clients("corev1").patch_namespaced_config_map(name, namespace, configmap) + + @classmethod + def delete_configmap(cls, namespace: str, name: str, api_client: Optional[client.ApiClient] = None): + """Delete a ConfigMap in a given namespace with the given name.""" + cls.clients("corev1", api_client=api_client).delete_namespaced_config_map(name, namespace) + + @classmethod + def delete_service(cls, namespace: str, name: str): + """Delete a Service in a given namespace with the given name.""" + cls.clients("corev1").delete_namespaced_service(name, namespace) + + @classmethod + def create_namespace(cls, namespace_name): + """Create a namespace with the given name.""" + namespace = cls.clients("client").V1Namespace(metadata=cls.clients("client").V1ObjectMeta(name=namespace_name)) + cls.clients("corev1").create_namespace(namespace) + + @classmethod + def create_pod(cls, namespace: str, body: Dict): + cls.clients("corev1").create_namespaced_pod(body=body, namespace=namespace) + + @classmethod + def delete_pod(cls, namespace: str, name: str): + """Delete a Pod in a given namespace with the given name.""" + cls.clients("corev1").delete_namespaced_pod(name, namespace) + + @classmethod + def create_deployment(cls, namespace: str, body: Dict): + cls.clients("appsv1").create_namespaced_deployment(body=body, namespace=namespace) + + @classmethod + def create_service( + cls, + namespace: str, + body: Dict, + api_client: Optional[kubernetes.client.ApiClient] = None, + ): + cls.clients("corev1", api_client=api_client).create_namespaced_service(body=body, namespace=namespace) + + @classmethod + def create_or_update_pvc(cls, namespace: str, body: Dict, storage_class_name: str = "gp2"): + if storage_class_name is not None: + body["spec"]["storageClassName"] = storage_class_name + try: + cls.clients("corev1").create_namespaced_persistent_volume_claim(body=body, namespace=namespace) + except client.rest.ApiException as e: + if e.status == 409: + cls.clients("corev1").patch_namespaced_persistent_volume_claim(body=body, namespace=namespace) + + @classmethod + def delete_pvc(cls, namespace: str, name: str): + cls.clients("corev1").delete_namespaced_persistent_volume_claim(name, namespace=namespace) + + @classmethod + def delete_namespace(cls, name): + """Delete the specified namespace.""" + cls.clients("corev1").delete_namespace(name, body=cls.clients("client").V1DeleteOptions()) + + @classmethod + def delete_deployment(cls, namespace: str, name): + cls.clients("appsv1").delete_namespaced_deployment(name, namespace) + + @staticmethod + def clients(name, api_client: Optional[client.ApiClient] = None): + return { + "client": client, + "corev1": client.CoreV1Api(api_client=api_client), + "appsv1": client.AppsV1Api(api_client=api_client), + "storagev1": client.StorageV1Api(api_client=api_client), + "customv1": client.CustomObjectsApi(api_client=api_client), + "certificates": client.CertificatesV1beta1Api(api_client=api_client), + "namespace": KubernetesTester.get_namespace(), + }[name] + + @classmethod + def teardown_class(cls): + "Tears down testing class, make sure pytest ends after tests are run." + cls.teardown_env() + sys.stdout.flush() + + @classmethod + def doc_string_to_init(cls, doc_string) -> dict: + result = yaml.safe_load(doc_string) + for m in ["create", "update"]: + if m in result and "patch" in result[m]: + result[m]["patch"] = json.loads(result[m]["patch"]) + return result + + @classmethod + def setup_class(cls): + "Will setup class (initialize kubernetes objects)" + print("\n") + KubernetesTester.load_configuration() + # Loads the subclass doc + if cls.init is None and cls.__doc__: + cls.init = cls.doc_string_to_init(cls.__doc__) + + if cls.init: + cls.prepare(cls.init, KubernetesTester.get_namespace()) + + cls.setup_env() + + @staticmethod + def load_configuration(): + "Loads kubernetes client configuration from kubectl config or incluster." + try: + config.load_kube_config() + except Exception: + config.load_incluster_config() + + @staticmethod + def get_namespace(): + return get_env_var_or_fail("NAMESPACE") + + @staticmethod + def get_om_group_name(): + return KubernetesTester.get_namespace() + + @staticmethod + def get_om_base_url(): + return get_env_var_or_fail("OM_HOST") + + @staticmethod + def get_om_user(): + return get_env_var_or_fail("OM_USER") + + @staticmethod + def get_om_api_key(): + return get_env_var_or_fail("OM_API_KEY") + + @staticmethod + def get_om_org_id(): + "Gets Organization ID. Makes sure to return None if it is not present" + + org_id = None + # Do not fail if OM_ORGID is not set + try: + org_id = get_env_var_or_fail("OM_ORGID") + except ValueError: + pass + + if isinstance(org_id, str) and org_id.strip() == "": + org_id = None + + return org_id + + @staticmethod + def get_om_group_id(group_name=None, org_id=None): + # doing some "caching" for the group id on the first invocation + if (KubernetesTester.group_id is None) or group_name or org_id: + group_name = group_name or KubernetesTester.get_om_group_name() + + org_id = org_id or KubernetesTester.get_om_org_id() + + group = KubernetesTester.query_group(group_name, org_id) + + KubernetesTester.group_id = group["id"] + + return KubernetesTester.group_id + + @classmethod + def prepare(cls, test_setup, namespace): + allowed_actions = ["create", "create_many", "update", "delete", "noop", "wait"] + for action in [action for action in allowed_actions if action in test_setup]: + rules = test_setup[action] + + if not isinstance(rules, list): + rules = [rules] + + for rule in rules: + KubernetesTester.execute(action, rule, namespace) + + # We wait for some time until checking the condition. This is important for updates: the resource was + # in "running" state, it got updated and it gets to "reconciling" and to "running" again. + # TODO ideally we need to check for the sequence of phases, e.g. "reconciling" -> "running" and remove the + # timeout + time.sleep(5) + + if "wait_for_condition" in rule: + cls.wait_for_condition_string(rule["wait_for_condition"]) + elif "wait_for_message" in rule: + cls.wait_for_status_message(rule) + else: + cls.wait_condition(rule) + + @staticmethod + def execute(action, rules, namespace): + "Execute function with name `action` with arguments `rules` and `namespace`" + getattr(KubernetesTester, action)(rules, namespace) + + @staticmethod + def wait_for(seconds): + "Will wait for a given amount of seconds." + time.sleep(int(seconds)) + + @staticmethod + def wait(rules, namespace): + KubernetesTester.name = rules["resource"] + KubernetesTester.wait_until(rules["until"], rules.get("timeout", 0)) + + @staticmethod + def create(section, namespace): + "creates a custom object from filename" + resource = yaml.safe_load(open(fixture(section["file"]))) + KubernetesTester.create_custom_resource_from_object( + namespace, + resource, + exception_reason=section.get("exception", None), + patch=section.get("patch", None), + ) + + @staticmethod + def create_many(section, namespace): + "creates multiple custom objects from a yaml list" + resources = yaml.safe_load(open(fixture(section["file"]))) + for res in resources: + name, kind = KubernetesTester.create_custom_resource_from_object( + namespace, + res, + exception_reason=section.get("exception", None), + patch=section.get("patch", None), + ) + + @staticmethod + def create_mongodb_from_file(namespace, file_path): + name, kind = KubernetesTester.create_custom_resource_from_file(namespace, file_path) + KubernetesTester.namespace = namespace + KubernetesTester.name = name + KubernetesTester.kind = kind + + @staticmethod + def create_custom_resource_from_file(namespace, file_path): + with open(file_path) as f: + resource = yaml.safe_load(f) + return KubernetesTester.create_custom_resource_from_object(namespace, resource) + + @staticmethod + def create_mongodb_from_object(namespace, resource, exception_reason=None, patch=None): + name, kind = KubernetesTester.create_custom_resource_from_object(namespace, resource, exception_reason, patch) + KubernetesTester.namespace = namespace + KubernetesTester.name = name + KubernetesTester.kind = kind + + @staticmethod + def create_custom_resource_from_object( + namespace, + resource, + exception_reason=None, + patch=None, + api_client: Optional[client.ApiClient] = None, + ): + name, kind, group, version, res_type = get_crd_meta(resource) + if patch: + patch = jsonpatch.JsonPatch(patch) + resource = patch.apply(resource) + + KubernetesTester.namespace = namespace + KubernetesTester.name = name + KubernetesTester.kind = kind + + # For some long-running actions (e.g. creation of OpsManager) we may want to reuse already existing CR + if os.getenv("SKIP_EXECUTION") == "true": + print("Skipping creation as 'SKIP_EXECUTION' env variable is not empty") + return + + print("Creating resource {} {} {}".format(kind, name, "(" + res_type + ")" if kind == "MongoDb" else "")) + + # TODO move "wait for exception" logic to a generic function and reuse for create/update/delete + try: + KubernetesTester.clients("customv1", api_client=api_client).create_namespaced_custom_object( + group, version, namespace, plural(kind), resource + ) + except ApiException as e: + if isinstance(e.body, str): + # In Kubernetes v1.16+ the result body is a json string that needs to be parsed, according to + # whatever exception_reason was passed. + try: + body_json = json.loads(e.body) + except json.decoder.JSONDecodeError: + # The API did not return a JSON string + pass + else: + reason = validation_reason_from_exception(exception_reason) + + if reason is not None: + field = exception_reason.split()[0] + for cause in body_json["details"]["causes"]: + if cause["reason"] == reason and cause["field"] == field: + return None, None + + if exception_reason: + assert e.reason == exception_reason or exception_reason in e.body, "Real exception is: {}".format(e) + print('"{}" exception raised while creating the resource - this is expected!'.format(exception_reason)) + return None, None + + print("Failed to create a resource ({}): \n {}".format(e, resource)) + raise + + else: + if exception_reason: + raise AssertionError("Expected ApiException, but create operation succeeded!") + + print("Created resource {} {} {}".format(kind, name, "(" + res_type + ")" if kind == "MongoDb" else "")) + return name, kind + + @staticmethod + def update(section, namespace): + """ + Updates the resource in the "file" section, applying the jsonpatch in "patch" section. + + Python API client (patch_namespaced_custom_object) will send a "merge-patch+json" by default. + This means that the resulting objects, after the patch is the union of the old and new objects. The + patch can only change attributes or add, but not delete, as it is the case with "json-patch+json" + requests. The json-patch+json requests are the ones used by `kubectl edit` and `kubectl patch`. + + # TODO: + A fix for this has been merged already (https://github.com/kubernetes-client/python/issues/862). The + Kubernetes Python module should be updated when the client is regenerated (version 10.0.1 or so) + + # TODO 2 (fixed in 10.0.1): As of 10.0.0 the patch gets completely broken: https://github.com/kubernetes-client/python/issues/866 + ("reason":"UnsupportedMediaType","code":415) + So we still should be careful with "remove" operation - better use "replace: null" + """ + resource = yaml.safe_load(open(fixture(section["file"]))) + + patch = section.get("patch") + KubernetesTester.patch_custom_resource_from_object(namespace, resource, patch) + + @staticmethod + def patch_custom_resource_from_object(namespace, resource, patch): + name, kind, group, version, res_type = get_crd_meta(resource) + KubernetesTester.namespace = namespace + KubernetesTester.name = name + KubernetesTester.kind = kind + + if patch is not None: + patch = jsonpatch.JsonPatch(patch) + resource = patch.apply(resource) + + # For some long-running actions (e.g. update of OpsManager) we may want to reuse already existing CR + if os.getenv("SKIP_EXECUTION") == "true": + print("Skipping creation as 'SKIP_EXECUTION' env variable is not empty") + return + + try: + # TODO currently if the update doesn't pass (e.g. patch is incorrect) - we don't fail here... + KubernetesTester.clients("customv1").patch_namespaced_custom_object( + group, version, namespace, plural(kind), name, resource + ) + except Exception: + print("Failed to update a resource ({}): \n {}".format(sys.exc_info()[0], resource)) + raise + print("Updated resource {} {} {}".format(kind, name, "(" + res_type + ")" if kind == "MongoDb" else "")) + + @staticmethod + def delete(section, namespace): + "delete custom object" + delete_name = section.get("delete_name") + loaded_yaml = yaml.safe_load(open(fixture(section["file"]))) + + resource = None + if delete_name is None: + resource = loaded_yaml + else: + # remove the element by name in the case of a list of elements + resource = [res for res in loaded_yaml if res["metadata"]["name"] == delete_name][0] + + name, kind, group, version, _ = get_crd_meta(resource) + + KubernetesTester.delete_custom_resource(namespace, name, kind, group, version) + + @staticmethod + def delete_custom_resource(namespace, name, kind, group="mongodb.com", version="v1"): + print("Deleting resource {} {}".format(kind, name)) + + KubernetesTester.namespace = namespace + KubernetesTester.name = name + KubernetesTester.kind = kind + + del_options = KubernetesTester.clients("client").V1DeleteOptions() + + KubernetesTester.clients("customv1").delete_namespaced_custom_object( + group, version, namespace, plural(kind), name, body=del_options + ) + print("Deleted resource {} {}".format(kind, name)) + + @staticmethod + def noop(section, namespace): + "noop action" + pass + + @staticmethod + def get_namespaced_custom_object(namespace, name, kind, group="mongodb.com", version="v1"): + return KubernetesTester.clients("customv1").get_namespaced_custom_object( + group, version, namespace, plural(kind), name + ) + + @staticmethod + def get_resource(): + """Assumes a single resource in the test environment""" + return KubernetesTester.get_namespaced_custom_object( + KubernetesTester.namespace, KubernetesTester.name, KubernetesTester.kind + ) + + @staticmethod + def in_error_state(): + return KubernetesTester.check_phase( + KubernetesTester.namespace, + KubernetesTester.kind, + KubernetesTester.name, + "Failed", + ) + + @staticmethod + def in_updated_state(): + return KubernetesTester.check_phase( + KubernetesTester.namespace, + KubernetesTester.kind, + KubernetesTester.name, + "Updated", + ) + + @staticmethod + def in_pending_state(): + return KubernetesTester.check_phase( + KubernetesTester.namespace, + KubernetesTester.kind, + KubernetesTester.name, + "Pending", + ) + + @staticmethod + def in_running_state(): + """Returns true if the resource in Running state, fails fast if got into Failed error. + This allows to fail fast in case of cascade failures""" + resource = KubernetesTester.get_resource() + if "status" not in resource: + return False + phase = resource["status"]["phase"] + + # TODO we need to implement a more reliable mechanism to diagnose problems in the cluster. So + # far we just ignore the "Pending" errors below, but they could be caused by real problems - not + # just by long starting containers. Some ideas: we could check the conditions for pods to see if there + # are errors + intermediate_events = ( + # In this case the operator will be waiting for the StatefulSet to be in full running state + # which under some circumstances, might not be the case if, for instance, there are too many + # pods to start, which will be concluded after a few reconciliation passes. + "Statefulset or its pods failed to reach READY state", + # After agents have been installed, they might have not finished or reached goal state yet. + "haven't reached READY", + "Some agents failed to register", + # Sometimes Cloud-QA timeouts so we anticipate to this + "Error sending GET request to", + # "Get https://cloud-qa.mongodb.com/api/public/v1.0/groups/5f186b406c835e37e6160aef/automationConfig: + # read tcp 10.244.0.6:33672->75.2.105.99:443: read: connection reset by peer" + "read: connection reset by peer", + ) + + if phase == "Failed": + msg = resource["status"]["message"] + # Sometimes (for sharded cluster for example) the Automation agents don't get on time - we + # should survive this + + found = False + for event in intermediate_events: + if event in msg: + found = True + + if not found: + raise AssertionError('Got into Failed phase while waiting for Running! ("{}")'.format(msg)) + + return phase == "Running" + + @staticmethod + def in_running_state_failures_possible(): + return KubernetesTester.check_phase( + KubernetesTester.namespace, + KubernetesTester.kind, + KubernetesTester.name, + "Running", + ) + + @staticmethod + def in_failed_state(): + return KubernetesTester.check_phase( + KubernetesTester.namespace, + KubernetesTester.kind, + KubernetesTester.name, + "Failed", + ) + + @staticmethod + def wait_for_status_message(rule): + timeout = int(rule.get("timeout", INFINITY)) + + def wait_for_status(): + res = KubernetesTester.get_namespaced_custom_object( + KubernetesTester.namespace, KubernetesTester.name, KubernetesTester.kind + ) + expected_message = rule["wait_for_message"] + message = res.get("status", {}).get("message", "") + if isinstance(expected_message, re.Pattern): + return expected_message.match(message) + return expected_message in message + + return KubernetesTester.wait_until(wait_for_status, timeout) + + @staticmethod + def is_deleted(namespace, name, kind="MongoDB"): + try: + KubernetesTester.get_namespaced_custom_object(namespace, name, kind) + return False + except ApiException: # ApiException is thrown when the object does not exist + return True + + @staticmethod + def check_phase(namespace, kind, name, phase): + resource = KubernetesTester.get_namespaced_custom_object(namespace, name, kind) + if "status" not in resource: + return False + return resource["status"]["phase"] == phase + + @classmethod + def wait_condition(cls, action): + """Waits for a condition to occur before proceeding, + or for some amount of time, both can appear in the file, + will always wait for the condition and then for some amount of time. + """ + if "wait_until" not in action: + return + + print("Waiting until {}".format(action["wait_until"])) + wait_phases = [a.strip() for a in action["wait_until"].split(",") if a != ""] + for phase in wait_phases: + # Will wait for each action passed as a , separated list + # waiting on average the same amount of time for each phase + # totaling `timeout` + cls.wait_until(phase, int(action.get("timeout", 0)) / len(wait_phases)) + + @classmethod + def wait_until(cls, action, timeout=0, **kwargs): + func = None + # if passed a function directly, we can use it + if callable(action): + func = action + else: # otherwise find a function of that name + func = getattr(cls, action) + return run_periodically(func, timeout=timeout, **kwargs) + + @classmethod + def wait_for_condition_string(cls, condition): + """Waits for a given condition from the cluster + Example: + 1. statefulset/my-replica-set -> status.current_replicas == 5 + """ + type_, name, attribute, expected_value = parse_condition_str(condition) + + if type_ not in ["sts", "statefulset"]: + raise NotImplementedError("Only StatefulSets can be tested with condition strings for now") + + return cls.wait_for_condition_stateful_set(cls.get_namespace(), name, attribute, expected_value) + + @classmethod + def wait_for_condition_stateful_set(cls, namespace, name, attribute, expected_value): + appsv1 = KubernetesTester.clients("appsv1") + namespace = KubernetesTester.get_namespace() + ready_to_go = False + while not ready_to_go: + try: + sts = appsv1.read_namespaced_stateful_set(name, namespace) + ready_to_go = get_nested_attribute(sts, attribute) == expected_value + except ApiException: + pass + + if ready_to_go: + return + + time.sleep(0.5) + + def setup_method(self): + self.client = client + self.corev1 = client.CoreV1Api() + self.appsv1 = client.AppsV1Api() + self.certificates = client.CertificatesV1beta1Api() + self.customv1 = client.CustomObjectsApi() + self.namespace = KubernetesTester.get_namespace() + self.name = None + self.kind = None + + @staticmethod + def create_group(org_id, group_name): + """ + Creates the group with specified name and organization id in Ops Manager, returns its ID + """ + url = build_om_groups_endpoint(KubernetesTester.get_om_base_url()) + response = KubernetesTester.om_request("post", url, {"name": group_name, "orgId": org_id}) + + return response.json()["id"] + + @staticmethod + def ensure_group(org_id, group_name): + try: + return KubernetesTester.get_om_group_id(group_name=group_name, org_id=org_id) + except: + return KubernetesTester.create_group(org_id, group_name) + + @staticmethod + def query_group(group_name, org_id=None): + """ + Obtains the group id of the group with specified name. + Note, that the logic used imitates the logic used by the Operator, 'getByName' returns all groups in all + organizations which may be inconvenient for local development as may result in "may groups exist" exception + """ + if org_id is None: + # If no organization is passed, then look for all organizations + org_id = KubernetesTester.find_organizations(group_name) + + if not isinstance(org_id, list): + org_id = [org_id] + + if len(org_id) != 1: + raise Exception('{} organizations with name "{}" found instead of 1!'.format(len(org_id), group_name)) + + group_ids = KubernetesTester.find_groups_in_organization(org_id[0], group_name) + if len(group_ids) != 1: + raise Exception( + f'{len(group_ids)} groups with name "{group_name}" found inside organization "{org_id[0]}" instead of 1!' + ) + url = build_om_group_endpoint(KubernetesTester.get_om_base_url(), group_ids[0]) + response = KubernetesTester.om_request("get", url) + + return response.json() + + @staticmethod + def remove_group(group_id): + url = build_om_group_endpoint(KubernetesTester.get_om_base_url(), group_id) + KubernetesTester.om_request("delete", url) + + @staticmethod + def remove_group_by_name(group_name): + orgid = KubernetesTester.get_om_org_id() + project_id = KubernetesTester.query_group(group_name, orgid)["id"] + KubernetesTester.remove_group(project_id) + + @staticmethod + def create_organization(org_name): + """ + Creates the organization with specified name in Ops Manager, returns its ID + """ + url = build_om_org_endpoint(KubernetesTester.get_om_base_url()) + response = KubernetesTester.om_request("post", url, {"name": org_name}) + + return response.json()["id"] + + @staticmethod + def find_organizations(org_name): + """ + Finds all organization with specified name, iterates over max 200 pages to find all matching organizations + (aligned with 'ompaginator.TraversePages'). + + If the Organization ID has been defined, return that instead. This is required to avoid 500's in Cloud Manager. + Returns the list of ids. + """ + org_id = KubernetesTester.get_om_org_id() + if org_id is not None: + return [org_id] + + ids = [] + page = 1 + while True: + url = build_om_org_list_endpoint(KubernetesTester.get_om_base_url(), page) + json = KubernetesTester.om_request("get", url).json() + + # Add organization id if its name is the searched one + ids.extend([org["id"] for org in json["results"] if org["name"] == org_name]) + + if not any(link["rel"] == "next" for link in json["links"]): + break + page += 1 + + return ids + + @staticmethod + def remove_organization(org_id): + """ + Removes the organization with specified id from Ops Manager + """ + url = build_om_one_org_endpoint(KubernetesTester.get_om_base_url(), org_id) + KubernetesTester.om_request("delete", url) + + @staticmethod + def get_groups_in_organization_first_page(org_id): + """ + :return: the first page of groups (100 items for OM 4.0 and 500 for OM 4.1) + """ + url = build_om_groups_in_org_endpoint(KubernetesTester.get_om_base_url(), org_id, 1) + response = KubernetesTester.om_request("get", url) + + return response.json() + + @staticmethod + def find_groups_in_organization(org_id, group_name): + """ + Finds all group with specified name, iterates over max 200 pages to find all matching groups inside the + organization (aligned with 'ompaginator.TraversePages') + Returns the list of ids. + """ + + max_pages = 200 + ids = [] + for i in range(1, max_pages): + url = build_om_groups_in_org_endpoint(KubernetesTester.get_om_base_url(), org_id, i) + json = KubernetesTester.om_request("get", url).json() + # Add group id if its name is the searched one + ids.extend([group["id"] for group in json["results"] if group["name"] == group_name]) + + if not any(link["rel"] == "next" for link in json["links"]): + break + + if len(ids) == 0: + print( + "Group name {} not found in organization with id {} (in {} pages)".format(group_name, org_id, max_pages) + ) + + return ids + + @staticmethod + def get_automation_config(group_id=None): + if group_id is None: + group_id = KubernetesTester.get_om_group_id() + + url = build_automation_config_endpoint(KubernetesTester.get_om_base_url(), group_id) + response = KubernetesTester.om_request("get", url) + + return response.json() + + @staticmethod + def get_monitoring_config(group_id=None): + if group_id is None: + group_id = KubernetesTester.get_om_group_id() + url = build_monitoring_config_endpoint(KubernetesTester.get_om_base_url(), group_id) + response = KubernetesTester.om_request("get", url) + + return response.json() + + @staticmethod + def put_automation_config(config): + url = build_automation_config_endpoint(KubernetesTester.get_om_base_url(), KubernetesTester.get_om_group_id()) + response = KubernetesTester.om_request("put", url, config) + + return response + + @staticmethod + def put_monitoring_config(config, group_id=None): + if group_id is None: + group_id = KubernetesTester.get_om_group_id() + url = build_monitoring_config_endpoint(KubernetesTester.get_om_base_url(), group_id) + response = KubernetesTester.om_request("put", url, config) + + return response + + @staticmethod + def get_hosts(): + url = build_hosts_endpoint(KubernetesTester.get_om_base_url(), KubernetesTester.get_om_group_id()) + response = KubernetesTester.om_request("get", url) + + return response.json() + + @staticmethod + def om_request(method, endpoint, json_object=None): + headers = {"Content-Type": "application/json"} + auth = build_auth(KubernetesTester.get_om_user(), KubernetesTester.get_om_api_key()) + + response = requests.request(method, endpoint, auth=auth, headers=headers, json=json_object) + + if response.status_code >= 300: + raise Exception( + "Error sending request to Ops Manager API. {} ({}).\n Request details: {} {} (data: {})".format( + response.status_code, response.text, method, endpoint, json_object + ) + ) + + return response + + @staticmethod + def om_version() -> Optional[Dict[str, str]]: + "Gets the X-MongoDB-Service-Version" + response = KubernetesTester.om_request( + "get", + "{}/api/public/v1.0/groups".format(KubernetesTester.get_om_base_url()), + ) + + version = response.headers.get("X-MongoDB-Service-Version") + if version is None: + return None + + return dict(attr.split("=", 1) for attr in version.split("; ")) + + @staticmethod + def check_om_state_cleaned(): + """Checks that OM state is cleaned: Automation config is empty, monitoring hosts are removed""" + + config = KubernetesTester.get_automation_config() + assert len(config["replicaSets"]) == 0, "ReplicaSets not empty: {}".format(config["replicaSets"]) + assert len(config["sharding"]) == 0, "Sharding not empty: {}".format(config["sharding"]) + assert len(config["processes"]) == 0, "Processes not empty: {}".format(config["processes"]) + + hosts = KubernetesTester.get_hosts() + assert len(hosts["results"]) == 0, "Hosts not empty: ({} hosts left)".format(len(hosts["results"])) + + @staticmethod + def is_om_state_cleaned(): + config = KubernetesTester.get_automation_config() + hosts = KubernetesTester.get_hosts() + + return ( + len(config["replicaSets"]) == 0 + and len(config["sharding"]) == 0 + and len(config["processes"]) == 0 + and len(hosts["results"]) == 0 + ) + + @staticmethod + def mongo_resource_deleted(check_om_state=True): + # First we check that the MDB resource is removed + # This depends on global state set by "create_custom_resouce", this means + # that it can't be called independently, or, calling the remove function without + # calling the "create" function first. + # Should not depend in the global state of KubernetesTester + + deleted_in_k8 = KubernetesTester.is_deleted( + KubernetesTester.namespace, KubernetesTester.name, KubernetesTester.kind + ) + + # Then we check that the resource was removed in Ops Manager if specified + return deleted_in_k8 if not check_om_state else (deleted_in_k8 and KubernetesTester.is_om_state_cleaned()) + + @staticmethod + def mongo_resource_deleted_no_om(): + """ + Waits until the MDB resource dissappears but won't wait for OM state to be removed, as sometimes + OM will just fail on us and make the test fail. + """ + return KubernetesTester.mongo_resource_deleted(False) + + def build_mongodb_uri_for_rs(self, hosts): + return "mongodb://{}".format(",".join(hosts)) + + @staticmethod + def random_k8s_name(prefix="test-"): + """Deprecated: user kubetester.random_k8s_name instead.""" + return prefix + "".join(random.choice(string.ascii_lowercase) for _ in range(5)) + + @staticmethod + def random_om_project_name() -> str: + """Generates the name for the projects with our common namespace (and project) convention so that + GC process could remove it if it's left for some reasons. Always has a whitespace. + """ + current_seconds_epoch = int(time.time()) + prefix = f"a-{current_seconds_epoch}-" + + return "{} {}".format( + KubernetesTester.random_k8s_name(prefix), + KubernetesTester.random_k8s_name(""), + ) + + @staticmethod + def run_command_in_pod_container( + pod_name: str, + namespace: str, + cmd: List[str], + container: str = "", + api_client: Optional[kubernetes.client.ApiClient] = None, + ) -> str: + api_client = client.CoreV1Api(api_client=api_client) + api_response = stream( + api_client.connect_get_namespaced_pod_exec, + pod_name, + namespace, + container=container, + command=cmd, + stdout=True, + stderr=True, + ) + return api_response + + @staticmethod + def copy_file_inside_pod(pod_name, src_path, dest_path, namespace="default"): + """ + This function copies a file inside the pod from localhost. (Taken from: https://stackoverflow.com/questions/59703610/copy-file-from-pod-to-host-by-using-kubernetes-python-client) + :param api_instance: coreV1Api() + :param name: pod name + :param ns: pod namespace + :param source_file: Path of the file to be copied into pod + """ + + api_client = client.CoreV1Api() + try: + exec_command = ["tar", "xvf", "-", "-C", "/"] + api_response = stream( + api_client.connect_get_namespaced_pod_exec, + pod_name, + namespace, + command=exec_command, + stderr=True, + stdin=True, + stdout=True, + tty=False, + _preload_content=False, + ) + + with tempfile.TemporaryFile() as tar_buffer: + with tarfile.open(fileobj=tar_buffer, mode="w") as tar: + tar.add(src_path, dest_path) + + tar_buffer.seek(0) + commands = [] + commands.append(tar_buffer.read()) + + while api_response.is_open(): + api_response.update(timeout=1) + if api_response.peek_stdout(): + print("STDOUT: %s" % api_response.read_stdout()) + if api_response.peek_stderr(): + print("STDERR: %s" % api_response.read_stderr()) + if commands: + c = commands.pop(0) + api_response.write_stdin(c.decode()) + else: + break + api_response.close() + except ApiException as e: + raise Exception("Failed to copy file to the pod: {}".format(e)) + + @staticmethod + def approve_certificate(name: str): + warnings.warn( + DeprecationWarning( + "KubernetesTester.approve_certificate is deprecated, use kubetester.certs.approve_certificate instead!" + ) + ) + # TODO: remove this method entirely + from kubetester.certs import approve_certificate + + return approve_certificate(name) + + def generate_certfile( + self, + csr_name: str, + certificate_request_fixture: str, + server_pem_fixture: str, + namespace: Optional[str] = None, + ): + """ + generate_certfile create a temporary file object that is created from a certificate request fixture + as well as a fixture containing the server pem key. This file can be used to pass to a MongoClient + when using MONGODB-X509 authentication + + :param csr_name: The name of the CSR that is to be created + :param certificate_request_fixture: a fixture containing the contents of the certificate request + :param server_pem_fixture: a fixture containing the server pem key file + :return: A File object containing the key and certificate + """ + with open(fixture(certificate_request_fixture), "r") as f: + encoded_request = b64encode(f.read().encode("utf-8")).decode("utf-8") + + if namespace is None: + namespace = self.namespace + + csr_body = client.V1beta1CertificateSigningRequest( + metadata=client.V1ObjectMeta(name=csr_name, namespace=namespace), + spec=client.V1beta1CertificateSigningRequestSpec( + groups=["system:authenticated"], + usages=["digital signature", "key encipherment", "client auth"], + request=encoded_request, + ), + ) + + client.CertificatesV1beta1Api().create_certificate_signing_request(csr_body) + self.approve_certificate(csr_name) + wait_for_certs_to_be_issued([csr_name]) + csr = client.CertificatesV1beta1Api().read_certificate_signing_request(csr_name) + certificate = b64decode(csr.status.certificate) + + tmp = tempfile.NamedTemporaryFile() + with open(fixture(server_pem_fixture), "r+b") as f: + key = f.read() + tmp.write(key) + tmp.write(certificate) + tmp.flush() + + return tmp + + @staticmethod + def list_storage_class() -> List[client.V1StorageClass]: + """Returns a list of all the Storage classes in this cluster.""" + return KubernetesTester.clients("storagev1").list_storage_class().items + + @staticmethod + def get_storage_class_provisioner_enabled() -> str: + """Returns 'a' provisioner that is known to exist in this cluster.""" + # If there's no storageclass in this cluster, then the following + # will raise a KeyError. + return KubernetesTester.list_storage_class()[0].provisioner + + @staticmethod + def create_storage_class(name: str, provisioner: Optional[str] = None) -> None: + """Creates a new StorageClass which is a duplicate of an existing one.""" + if provisioner is None: + provisioner = KubernetesTester.get_storage_class_provisioner_enabled() + + sc0 = KubernetesTester.list_storage_class()[0] + + sc = client.V1StorageClass( + metadata=client.V1ObjectMeta( + name=name, + annotations={"storageclass.kubernetes.io/is-default-class": "true"}, + ), + provisioner=provisioner, + volume_binding_mode=sc0.volume_binding_mode, + reclaim_policy=sc0.reclaim_policy, + ) + KubernetesTester.clients("storagev1").create_storage_class(sc) + + @staticmethod + def storage_class_make_not_default(name: str): + """Changes the 'default' annotation from a storage class.""" + sv1 = KubernetesTester.clients("storagev1") + sc = sv1.read_storage_class(name) + sc.metadata.annotations["storageclass.kubernetes.io/is-default-class"] = "false" + sv1.patch_storage_class(name, sc) + + @staticmethod + def make_default_gp2_storage_class(): + """ + gp2 is an aws-ebs storage class, make sure to only use that on aws based tests + """ + classes = KubernetesTester.list_storage_class() + + for sc in classes: + if sc.metadata.name == "gp2": + # The required class already exist, no need to create it. + return + + KubernetesTester.create_storage_class("gp2") + KubernetesTester.storage_class_make_not_default("standard") + + @staticmethod + def yield_existing_csrs(csr_names, timeout=300): + warnings.warn( + DeprecationWarning( + "KubernetesTester.yield_existing_csrs is deprecated, use kubetester.certs.yield_existing_csrs instead!" + ) + ) + # TODO: remove this method entirely + from kubetester.certs import yield_existing_csrs + + return yield_existing_csrs(csr_names, timeout) + + # TODO eventually replace all usages of this function with "ReplicaSetTester(mdb_resource, 3).assert_connectivity()" + def wait_for_rs_is_ready(self, hosts, wait_for=60, check_every=5, ssl=False): + "Connects to a given replicaset and wait a while for a primary and secondaries." + client = self.check_hosts_are_ready(hosts, ssl) + + check_times = wait_for / check_every + + while (client.primary is None or len(client.secondaries) < len(hosts) - 1) and check_times >= 0: + time.sleep(check_every) + check_times -= 1 + + return client.primary, client.secondaries + + def check_hosts_are_ready(self, hosts, ssl=False): + mongodburi = self.build_mongodb_uri_for_rs(hosts) + options = {} + if ssl: + options = {"ssl": True, "ssl_ca_certs": SSL_CA_CERT} + client = pymongo.MongoClient(mongodburi, **options) + + # The ismaster command is cheap and does not require auth. + client.admin.command("ismaster") + + return client + + def _get_pods(self, podname, qty=3): + return [podname.format(i) for i in range(qty)] + + @staticmethod + def check_single_pvc( + namespace: str, + volume, + expected_name, + expected_claim_name, + expected_size, + storage_class=None, + labels: Optional[Dict[str, str]] = None, + ): + assert volume.name == expected_name + assert volume.persistent_volume_claim.claim_name == expected_claim_name + + pvc = client.CoreV1Api().read_namespaced_persistent_volume_claim(expected_claim_name, namespace) + assert pvc.status.phase == "Bound" + assert pvc.spec.resources.requests["storage"] == expected_size + + assert getattr(pvc.spec, "storage_class_name") == storage_class + if labels is not None: + pvc_labels = pvc.metadata.labels + for k in labels: + assert k in pvc_labels and pvc_labels[k] == labels[k] + + @staticmethod + def get_mongo_server_sans(host: str) -> List[str]: + cert_bytes = ssl.get_server_certificate((host, 27017)).encode("ascii") + cert = x509.load_pem_x509_certificate(cert_bytes, default_backend()) + ext = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName) + return ext.value.get_values_for_type(x509.DNSName) + + @staticmethod + def get_csr_sans(csr_name: str) -> List[str]: + """ + Return all of the subject alternative names for a given Kubernetes + certificate signing request. + """ + csr = client.CertificatesV1beta1Api().read_certificate_signing_request_status(csr_name) + base64_csr_request = csr.spec.request + csr_pem_string = b64decode(base64_csr_request) + csr = x509.load_pem_x509_csr(csr_pem_string, default_backend()) + ext = csr.extensions.get_extension_for_class(x509.SubjectAlternativeName) + return ext.value.get_values_for_type(x509.DNSName) + + +# Some general functions go here + + +def get_group(doc): + return doc["apiVersion"].split("/")[0] + + +def get_version(doc): + return doc["apiVersion"].split("/")[1] + + +def get_kind(doc): + return doc["kind"] + + +def get_name(doc): + return doc["metadata"]["name"] + + +def get_type(doc): + return doc.get("spec", {}).get("type") + + +def get_crd_meta(doc): + return get_name(doc), get_kind(doc), get_group(doc), get_version(doc), get_type(doc) + + +def plural(name): + """Returns the plural of the name, in the case of `mongodb` the plural is the same.""" + return plural_map[name] + + +def parse_condition_str(condition): + """ + Returns a condition into constituent parts: + >>> parse_condition_str('sts/my-replica-set -> status.current_replicas == 3') + >>> 'sts', 'my-replica-set', '{.status.currentReplicas}', '3' + """ + type_name, condition = condition.split("->") + type_, name = type_name.split("/") + type_ = type_.strip() + name = name.strip() + + test, expected = condition.split("==") + test = test.strip() + expected = expected.strip() + if expected.isdigit(): + expected = int(expected) + + return type_, name, test, expected + + +def get_nested_attribute(obj, attrs): + """Returns the `attrs` attribute descending into this object following the . notation. + Assume you have a class Some() and: + >>> class Some: pass + >>> a = Some() + >>> b = Some() + >>> c = Some() + >>> a.b = b + >>> b.c = c + >>> c.my_string = 'hello!' + >>> get_nested_attribute(a, 'b.c.my_string') + 'hello!' + """ + + attrs = list(reversed(attrs.split("."))) + while attrs: + obj = getattr(obj, attrs.pop()) + + return obj + + +def current_milliseconds(): + return int(round(time.time() * 1000)) + + +def run_periodically(fn, *args, **kwargs): + """ + Calls `fn` until it succeeds or until the `timeout` is reached, every `sleep_time` seconds. + If `timeout` is negative or zero, it never times out. + Callable fn can return single bool (condition result) or tuple[bool, str] ([condition result, status message]). + + >>> run_periodically(lambda: time.sleep(5), timeout=3, sleep_time=2) + False + >>> run_periodically(lambda: time.sleep(2), timeout=5, sleep_time=2) + True + """ + sleep_time = kwargs.get("sleep_time", SLEEP_TIME) + timeout = kwargs.get("timeout", INFINITY) + msg = kwargs.get("msg", None) + + start_time = current_milliseconds() + end = start_time + (timeout * 1000) + callable_name = fn.__name__ + + while current_milliseconds() < end or timeout <= 0: + fn_result = fn() + fn_condition_msg = None + if isinstance(fn_result, bool): + fn_condition = fn_result + elif isinstance(fn_result, tuple) and len(fn_result) == 2: + fn_condition = fn_result[0] + fn_condition_msg = fn_result[1] + else: + raise Exception("Invalid fn return type. Fn have to return either bool or a tuple[bool, str].") + + if fn_condition: + print( + "{} executed successfully after {} seconds".format( + callable_name, (current_milliseconds() - start_time) / 1000 + ) + ) + return True + if msg is not None: + condition_msg = f": {fn_condition_msg}" if fn_condition_msg is not None else "" + print(f"waiting for {msg}{condition_msg}...") + time.sleep(sleep_time) + + raise AssertionError( + "Timed out executing {} after {} seconds".format(callable_name, (current_milliseconds() - start_time) / 1000) + ) + + +def get_env_var_or_fail(name): + """ + Gets a configuration option from an Environment variable. If not found, will try to find + this option in one of the configuration files for the user. + """ + value = os.getenv(name) + + if value is None: + raise ValueError("Environment variable `{}` needs to be set.".format(name)) + + if isinstance(value, str): + value = value.strip() + + return value + + +def build_auth(user, api_key): + return HTTPDigestAuth(user, api_key) + + +def build_om_groups_endpoint(base_url): + return "{}/api/public/v1.0/groups".format(base_url) + + +def build_om_group_endpoint(base_url, group_id): + return "{}/api/public/v1.0/groups/{}".format(base_url, group_id) + + +def build_om_org_endpoint(base_url): + return "{}/api/public/v1.0/orgs".format(base_url) + + +def build_om_org_list_endpoint(base_url: string, page_num: int): + return "{}/api/public/v1.0/orgs?itemsPerPage=500&pageNum={}".format(base_url, page_num) + + +def build_om_org_list_by_name_endpoint(base_url: string, name: string): + return "{}/api/public/v1.0/orgs?name={}".format(base_url, name) + + +def build_om_one_org_endpoint(base_url, org_id): + return "{}/api/public/v1.0/orgs/{}".format(base_url, org_id) + + +def build_om_groups_in_org_endpoint(base_url, org_id, page_num): + return "{}/api/public/v1.0/orgs/{}/groups?itemsPerPage=500&pageNum={}".format(base_url, org_id, page_num) + + +def build_om_groups_in_org_by_name_endpoint(base_url: string, org_id: string, name: string): + return "{}/api/public/v1.0/orgs/{}/groups?name={}".format(base_url, org_id, name) + + +def build_automation_config_endpoint(base_url, group_id): + return "{}/api/public/v1.0/groups/{}/automationConfig".format(base_url, group_id) + + +def build_monitoring_config_endpoint(base_url, group_id): + return "{}/api/public/v1.0/groups/{}/automationConfig/monitoringAgentConfig".format(base_url, group_id) + + +def build_hosts_endpoint(base_url, group_id): + return "{}/api/public/v1.0/groups/{}/hosts".format(base_url, group_id) + + +def ensure_nested_objects(resource: CustomObject, keys: List[str]): + curr_dict = resource + for k in keys: + if k not in curr_dict: + curr_dict[k] = {} + curr_dict = curr_dict[k] + + +def fixture(filename): + """ + Returns a relative path to a filename in one of the fixture's directories + """ + root_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "tests") + + fixture_dirs = [] + + for dirpath, dirnames, filenames in os.walk(root_dir): + if dirpath.endswith("/fixtures"): + fixture_dirs.append(dirpath) + + found = None + for dirs in fixture_dirs: + full_path = os.path.join(dirs, filename) + if os.path.exists(full_path) and os.path.isfile(full_path): + if found is not None: + warnings.warn("Fixtures with the same name were found: {}".format(full_path)) + found = full_path + + if found is None: + raise Exception("Fixture file {} not found".format(filename)) + + return found + + +def build_list_of_hosts( + mdb_resource, + namespace, + members, + servicename=None, + clustername: str = "cluster.local", + port=27017, +): + if servicename is None: + servicename = "{}-svc".format(mdb_resource) + + return [ + build_host_fqdn(hostname(mdb_resource, idx), namespace, servicename, clustername, port) + for idx in range(members) + ] + + +def build_host_fqdn( + hostname: str, + namespace: str, + servicename: str, + clustername: str = "cluster.local", + port=27017, +) -> str: + return "{hostname}.{servicename}.{namespace}.svc.{clustername}:{port}".format( + hostname=hostname, + servicename=servicename, + namespace=namespace, + clustername=clustername, + port=port, + ) + + +def build_svc_fqdn(service: str, namespace: str, clustername: str = "cluster.local") -> str: + return "{}.{}.svc.{}".format(service, namespace, clustername) + + +def hostname(hostname, idx): + return "{}-{}".format(hostname, idx) + + +def get_pods(podname_format, qty=3): + return [podname_format.format(i) for i in range(qty)] + + +def decode_secret(data: Dict[str, str]) -> Dict[str, str]: + return {k: b64decode(v).decode("utf-8") for (k, v) in data.items()} + + +def validation_reason_from_exception(exception_msg): + reasons = [ + ("in body is required", "FieldValueRequired"), + ("in body should be one of", "FieldValueNotSupported"), + ("in body must be of type", "FieldValueInvalid"), + ] + + for reason in reasons: + if reason[0] in exception_msg: + return reason[1] + + +def create_testing_namespace( + evergreen_task_id: str, + name: str, + api_client: Optional[kubernetes.client.ApiClient] = None, + istio_label: Optional[bool] = False, +) -> str: + """creates the namespace that is used by the test. Marks it with necessary labels and annotations so that + it would be handled by configuration scripts correctly (cluster cleaner, dumping the diagnostics information) + """ + + labels = {"evg": "task"} + if istio_label: + labels.update({"istio-injection": "enabled"}) + + annotations = {"evg/task": f"https://evergreen.mongodb.com/task/{evergreen_task_id}"} + + from kubetester import create_or_update_namespace + + create_or_update_namespace(name, labels, annotations, api_client=api_client) + + return name + + +def fcv_from_version(version: str) -> str: + parsed_version = semver.VersionInfo.parse(version) + return f"{parsed_version.major}.{parsed_version.minor}" diff --git a/docker/mongodb-enterprise-tests/kubetester/ldap.py b/docker/mongodb-enterprise-tests/kubetester/ldap.py new file mode 100644 index 000000000..2851109a5 --- /dev/null +++ b/docker/mongodb-enterprise-tests/kubetester/ldap.py @@ -0,0 +1,173 @@ +from dataclasses import dataclass +import ldap +import ldap.modlist +import time +from typing import Optional + +LDAP_BASE = "dc=example,dc=org" +LDAP_AUTHENTICATION_MECHANISM = "PLAIN" + + +@dataclass(init=True) +class OpenLDAP: + host: str + admin_password: str + ldap_base: str = LDAP_BASE + + @property + def servers(self): + return self.host.partition("//")[2] + + +@dataclass(init=True) +class LDAPUser: + uid: str + password: str + ldap_base: str = LDAP_BASE + ou: str = None + + @property + def username(self): + return build_dn(uid=self.uid, ou=self.ou, base=self.ldap_base) + + +def create_user( + server: OpenLDAP, + user: LDAPUser, + ca_path: Optional[str] = None, + ou: Optional[str] = None, + o: Optional[str] = None, +): + """Creates a new user in the LDAP database. It might include an optional organizational unit (ou).""" + con = ldap_initialize(server, ca_path) + + modlist = { + "objectClass": [b"top", b"account", b"simpleSecurityObject"], + "userPassword": [str.encode(user.password)], + "uid": [str.encode(user.uid)], + } + ldapmodlist = ldap.modlist.addModlist(modlist) + + dn = build_dn(uid=user.uid, ou=ou, o=o, base=server.ldap_base) + try: + con.add_s(dn, ldapmodlist) + except ldap.ALREADY_EXISTS as e: + pass + + +def ensure_organization(server: OpenLDAP, o: str, ca_path: Optional[str] = None): + """If an organizational unit with the provided name does not exists, it creates one.""" + con = ldap_initialize(server, ca_path) + + result = con.search_s(server.ldap_base, ldap.SCOPE_SUBTREE, filterstr="o=" + o) + if result is None: + raise Exception( + f"Error when trying to check for organization {o} in the ldap server" + ) + if len(result) != 0: + return + modlist = {"objectClass": [b"top", b"organization"], "o": [str.encode(o)]} + + ldapmodlist = ldap.modlist.addModlist(modlist) + + dn = build_dn(o=o, base=server.ldap_base) + con.add_s(dn, ldapmodlist) + + +def ensure_organizational_unit( + server: OpenLDAP, ou: str, o: Optional[str] = None, ca_path: Optional[str] = None +): + """If an organizational unit with the provided name does not exists, it creates one.""" + con = ldap_initialize(server, ca_path) + + result = con.search_s(server.ldap_base, ldap.SCOPE_SUBTREE, filterstr="ou=" + ou) + if result is None: + raise Exception( + f"Error when trying to check for organizationalUnit {ou} in the ldap server" + ) + if len(result) != 0: + return + modlist = {"objectClass": [b"top", b"organizationalUnit"], "ou": [str.encode(ou)]} + + ldapmodlist = ldap.modlist.addModlist(modlist) + + dn = build_dn(ou=ou, o=o, base=server.ldap_base) + con.add_s(dn, ldapmodlist) + + +def ensure_group( + server: OpenLDAP, + cn: str, + ou: str, + o: Optional[str] = None, + ca_path: Optional[str] = None, +): + """If a group with the provided name does not exists, it creates a group in the LDAP database, + that also belongs to an organizational unit. By default, it adds the admin user to it.""" + con = ldap_initialize(server, ca_path) + + result = con.search_s(server.ldap_base, ldap.SCOPE_SUBTREE, filterstr="cn=" + cn) + if result is None: + raise Exception(f"Error when trying to check for group {cn} in the ldap server") + if len(result) != 0: + return + unique_member = build_dn(base=server.ldap_base, uid="admin", ou=ou, o=o) + modlist = { + "objectClass": [b"top", b"groupOfUniqueNames"], + "cn": str.encode(cn), + "uniqueMember": str.encode(unique_member), + } + ldapmodlist = ldap.modlist.addModlist(modlist) + + dn = build_dn(base=server.ldap_base, cn=cn, ou=ou, o=o) + + con.add_s(dn, ldapmodlist) + + +def add_user_to_group( + server: OpenLDAP, + user: str, + group_cn: str, + ou: str, + o: Optional[str] = None, + ca_path: Optional[str] = None, +): + """Adds a new uniqueMember to a group, this is equivalent to add a user to the group.""" + con = ldap_initialize(server, ca_path) + + unique_member = build_dn(uid=user, ou=ou, o=o, base=server.ldap_base) + modlist = {"uniqueMember": [str.encode(unique_member)]} + ldapmodlist = ldap.modlist.modifyModlist({}, modlist) + + dn = build_dn(cn=group_cn, ou=ou, o=o, base=server.ldap_base) + try: + con.modify_s(dn, ldapmodlist) + except ldap.TYPE_OR_VALUE_EXISTS as e: + pass + + +def ldap_initialize(server: OpenLDAP, ca_path: Optional[str] = None, retries=0): + con = ldap.initialize(server.host) + + if server.host.startswith("ldaps://") and ca_path is not None: + con.set_option(ldap.OPT_X_TLS_CACERTFILE, ca_path) + con.set_option(ldap.OPT_X_TLS_NEWCTX, 0) + + dn_admin = build_dn(cn="admin", base=server.ldap_base) + r = retries + while r >= 0: + try: + con.simple_bind_s(dn_admin, server.admin_password) + return con + except ldap.SERVER_DOWN as e: + r -= 1 + time.sleep(5) + + +def build_dn(base: Optional[str] = None, **kwargs): + """Builds a distinguished name from arguments.""" + dn = ",".join("{}={}".format(k, v) for k, v in kwargs.items() if v is not None) + if base is not None: + dn += "," + base + + return dn diff --git a/docker/mongodb-enterprise-tests/kubetester/mongodb.py b/docker/mongodb-enterprise-tests/kubetester/mongodb.py new file mode 100644 index 000000000..8ff79cfc8 --- /dev/null +++ b/docker/mongodb-enterprise-tests/kubetester/mongodb.py @@ -0,0 +1,438 @@ +from __future__ import annotations + +import re +import time +import urllib.parse +from enum import Enum +from typing import Dict, List, Optional, Tuple + +from kubeobject import CustomObject +from kubernetes import client + +from kubetester.kubetester import ( + KubernetesTester, + build_host_fqdn, + ensure_nested_objects, +) +from kubetester.omtester import OMContext, OMTester +from .mongotester import ( + MongoTester, + ReplicaSetTester, + ShardedClusterTester, + StandaloneTester, +) + + +class Phase(Enum): + Running = 1 + Pending = 2 + Failed = 3 + Reconciling = 4 + Updated = 5 + Disabled = 6 + Unsupported = 7 + + +class MongoDBCommon: + def wait_for(self, fn, timeout=None, should_raise=False): + if timeout is None: + timeout = 360 + initial_timeout = timeout + + wait = 3 + while timeout > 0: + self.reload() + if fn(self): + return True + timeout -= wait + time.sleep(wait) + + if should_raise: + raise Exception("Timeout ({}) reached while waiting for {}".format(initial_timeout, self)) + + def get_generation(self) -> int: + return self.backing_obj["metadata"]["generation"] + + +class MongoDB(CustomObject, MongoDBCommon): + def __init__(self, *args, **kwargs): + with_defaults = { + "plural": "mongodb", + "kind": "MongoDB", + "group": "mongodb.com", + "version": "v1", + } + with_defaults.update(kwargs) + super(MongoDB, self).__init__(*args, **with_defaults) + + def assert_state_transition_happens(self, last_transition, timeout=None): + def transition_changed(mdb: MongoDB): + return mdb.get_status_last_transition_time() != last_transition + + self.wait_for(transition_changed, timeout) + + def assert_reaches_phase(self, phase: Phase, msg_regexp=None, timeout=None, ignore_errors=False): + intermediate_events = ( + "haven't reached READY", + "Some agents failed to register", + # Sometimes Cloud-QA timeouts so we anticipate to this + "Error sending GET request to", + # "Get https://cloud-qa.mongodb.com/api/public/v1.0/groups/5f186b406c835e37e6160aef/automationConfig: + # read tcp 10.244.0.6:33672->75.2.105.99:443: read: connection reset by peer" + "read: connection reset by peer", + # Ops Manager must be recovering from an Upgrade, and it is + # currently DOWN. + "connect: connection refused", + "MongoDB version information is not yet available", + # Enabling authentication is a lengthy process where the agents might not reach READY in time. + # That can cause a failure and a restart of the reconcile. + "Failed to enable Authentication", + ) + return self.wait_for( + lambda s: in_desired_state( + current_state=self.get_status_phase(), + desired_state=phase, + current_generation=self.get_generation(), + observed_generation=self.get_status_observed_generation(), + current_message=self.get_status_message(), + msg_regexp=msg_regexp, + ignore_errors=ignore_errors, + intermediate_events=intermediate_events, + ), + timeout, + should_raise=True, + ) + + def assert_abandons_phase(self, phase: Phase, timeout=None): + """This method can be racy by nature, it assumes that the operator is slow enough that its phase transition + happens during the time we call this method. If there is not a lot of work, then the phase can already finished + transitioning during the modification call before calling this method. + """ + return self.wait_for(lambda s: s.get_status_phase() != phase, timeout, should_raise=True) + + def assert_backup_reaches_status(self, expected_status: str, timeout: int = 600): + def reaches_backup_status(mdb: MongoDB) -> bool: + try: + return mdb["status"]["backup"]["statusName"] == expected_status + except KeyError: + return False + + self.wait_for(reaches_backup_status, timeout=timeout) + + def assert_status_resource_not_ready(self, name: str, kind: str = "StatefulSet", msg_regexp=None, idx=0): + """Checks the element in 'resources_not_ready' field by index 'idx'""" + assert self.get_status_resources_not_ready()[idx]["kind"] == kind + assert self.get_status_resources_not_ready()[idx]["name"] == name + assert re.search(msg_regexp, self.get_status_resources_not_ready()[idx]["message"]) is not None + + @property + def type(self) -> str: + return self["spec"]["type"] + + def tester( + self, + ca_path: Optional[str] = None, + srv: bool = False, + use_ssl: Optional[bool] = None, + ) -> MongoTester: + """Returns a Tester instance for this type of deployment.""" + if self.type == "ReplicaSet" and "clusterSpecList" in self["spec"]: + raise ValueError("A MongoDB class is being used to represent a MongoDBMulti instance!") + + if self.type == "ReplicaSet": + return ReplicaSetTester( + mdb_resource_name=self.name, + replicas_count=self["status"]["members"], + ssl=self.is_tls_enabled() if use_ssl is None else use_ssl, + srv=srv, + ca_path=ca_path, + namespace=self.namespace, + external_domain=self.get_external_domain(), + ) + elif self.type == "ShardedCluster": + return ShardedClusterTester( + mdb_resource_name=self.name, + mongos_count=self["spec"]["mongosCount"], + ssl=self.is_tls_enabled() if use_ssl is None else use_ssl, + srv=srv, + ca_path=ca_path, + namespace=self.namespace, + ) + elif self.type == "Standalone": + return StandaloneTester( + mdb_resource_name=self.name, + ssl=self.is_tls_enabled() if use_ssl is None else use_ssl, + ca_path=ca_path, + namespace=self.namespace, + external_domain=self.get_external_domain(), + ) + + def assert_connectivity(self, ca_path: Optional[str] = None): + return self.tester(ca_path=ca_path).assert_connectivity() + + def assert_connectivity_from_connection_string(self, cnx_string: str, tls: bool, ca_path: Optional[str] = None): + """ + Tries to connect to a database using a connection string only. + """ + return MongoTester(cnx_string, tls, ca_path).assert_connectivity() + + def __repr__(self): + # FIX: this should be __unicode__ + return "MongoDB ({})| status: {}| message: {}".format( + self.name, self.get_status_phase(), self.get_status_message() + ) + + def configure( + self, + om: MongoDBOpsManager, + project_name: str, + api_client: Optional[client.ApiClient] = None, + ) -> MongoDB: + if "project" in self["spec"]: + del self["spec"]["project"] + + ensure_nested_objects(self, ["spec", "opsManager", "configMapRef"]) + + self["spec"]["opsManager"]["configMapRef"]["name"] = om.get_or_create_mongodb_connection_config_map( + self.name, project_name, self.namespace, api_client=api_client + ) + # Note that if the MongoDB object is created in a different namespace than the Operator + # then the secret needs to be copied there manually + self["spec"]["credentials"] = om.api_key_secret(self.namespace, api_client=api_client) + return self + + def configure_backup(self, mode: str = "enabled") -> MongoDB: + ensure_nested_objects(self, ["spec", "backup"]) + self["spec"]["backup"]["mode"] = mode + return self + + def configure_custom_tls( + self, + issuer_ca_configmap_name: str, + tls_cert_secret_name: str, + ): + ensure_nested_objects(self, ["spec", "security", "tls"]) + self["spec"]["security"] = { + "certsSecretPrefix": tls_cert_secret_name, + "tls": {"enabled": True, "ca": issuer_ca_configmap_name}, + } + + def build_list_of_hosts(self): + """Returns the list of full_fqdn:27017 for every member of the mongodb resource""" + return [ + build_host_fqdn( + f"{self.name}-{idx}", + self.namespace, + self.get_service(), + self.get_cluster_domain(), + 27017, + ) + for idx in range(self.get_members()) + ] + + def read_statefulset(self) -> client.V1StatefulSet: + return client.AppsV1Api().read_namespaced_stateful_set(self.name, self.namespace) + + def read_configmap(self) -> Dict[str, str]: + return KubernetesTester.read_configmap(self.namespace, self.config_map_name) + + def mongo_uri(self, user_name: Optional[str] = None, password: Optional[str] = None) -> str: + """Returns the mongo uri for the MongoDB resource. The logic matches the one in 'types.go'""" + proto = "mongodb://" + auth = "" + params = {"connectTimeoutMS": "20000", "serverSelectionTimeoutMS": "20000"} + if "SCRAM" in self.get_authentication_modes(): + auth = "{}:{}@".format( + urllib.parse.quote(user_name, safe=""), + urllib.parse.quote(password, safe=""), + ) + params["authSource"] = "admin" + if self.get_version().startswith("3.6"): + params["authMechanism"] = "SCRAM-SHA-1" + else: + params["authMechanism"] = "SCRAM-SHA-256" + + hosts = ",".join(self.build_list_of_hosts()) + + if self.get_resource_type() == "ReplicaSet": + params["replicaSet"] = self.name + + if self.is_tls_enabled(): + params["ssl"] = "true" + + query_params = ["{}={}".format(key, params[key]) for key in sorted(params.keys())] + joined_params = "&".join(query_params) + return proto + auth + hosts + "/?" + joined_params + + def get_members(self) -> int: + return self["spec"]["members"] + + def get_version(self) -> str: + return self["spec"]["version"] + + def get_service(self) -> str: + try: + return self["spec"]["service"] + except KeyError: + return "{}-svc".format(self.name) + + def get_cluster_domain(self) -> Optional[str]: + try: + return self["spec"]["clusterDomain"] + except KeyError: + return "cluster.local" + + def get_resource_type(self) -> str: + return self["spec"]["type"] + + def is_tls_enabled(self): + """Checks if this object is TLS enabled.""" + try: + return self["spec"]["security"]["tls"]["enabled"] + except KeyError: + return False + + def set_version(self, version: str): + self["spec"]["version"] = version + return self + + def get_authentication(self) -> Optional[Dict]: + try: + return self["spec"]["security"]["authentication"] + except KeyError: + return {} + + def get_authentication_modes(self) -> Optional[Dict]: + try: + return self.get_authentication()["modes"] + except KeyError: + return {} + + def get_status_phase(self) -> Optional[Phase]: + try: + return Phase[self["status"]["phase"]] + except KeyError: + return None + + def get_status_last_transition_time(self) -> Optional[str]: + return self["status"]["lastTransition"] + + def get_status_message(self) -> Optional[str]: + try: + return self["status"]["message"] + except KeyError: + return None + + def get_status_observed_generation(self) -> Optional[int]: + try: + return self["status"]["observedGeneration"] + except KeyError: + return None + + def get_status_members(self) -> Optional[str]: + try: + return self["status"]["members"] + except KeyError: + return None + + def get_status_resources_not_ready(self) -> Optional[List[Dict]]: + try: + return self["status"]["resourcesNotReady"] + except KeyError: + return None + + def get_om_tester(self) -> OMTester: + """Returns the OMTester instance based on MongoDB connectivity parameters""" + config_map = self.read_configmap() + secret = KubernetesTester.read_secret(self.namespace, self["spec"]["credentials"]) + return OMTester(OMContext.build_from_config_map_and_secret(config_map, secret)) + + def get_automation_config_tester(self, **kwargs): + """This is just a shortcut for getting automation config tester for replica set""" + return self.get_om_tester().get_automation_config_tester(**kwargs) + + def get_external_domain(self): + return self["spec"].get("externalAccess", {}).get("externalDomain", None) + + @property + def config_map_name(self) -> str: + if "opsManager" in self["spec"]: + return self["spec"]["opsManager"]["configMapRef"]["name"] + return self["spec"]["project"] + + def config_srv_statefulset_name(self) -> str: + return self.name + "-config" + + def shards_statefulsets_names(self) -> List[str]: + return ["{}-{}".format(self.name, i) for i in range(1, self["spec"]["shardCount"])] + + class Types: + REPLICA_SET = "ReplicaSet" + SHARDED_CLUSTER = "ShardedCluster" + STANDALONE = "Standalone" + + +def get_pods(podname, qty) -> List[str]: + return [podname.format(i) for i in range(qty)] + + +def in_desired_state( + current_state: Phase, + desired_state: Phase, + current_generation: int, + observed_generation: int, + current_message: str, + msg_regexp: Optional[str] = None, + ignore_errors=False, + intermediate_events: Tuple = (), +) -> bool: + """Returns true if the current_state is equal to desired state, fails fast if got into Failed error. + Optionally checks if the message matches the specified regexp expression""" + if current_state is None: + return False + + if current_generation != observed_generation: + # We shouldn't check the status further if the Operator hasn't started working on the new spec yet + return False + + if current_state == Phase.Failed and not desired_state == Phase.Failed and not ignore_errors: + found = False + for event in intermediate_events: + if event in current_message: + found = True + + if not found: + raise AssertionError(f'Got into Failed phase while waiting for Running! ("{current_message}")') + + is_in_desired_state = current_state == desired_state + if msg_regexp is not None: + regexp = re.compile(msg_regexp) + is_in_desired_state = is_in_desired_state and current_message is not None and regexp.match(current_message) + + return is_in_desired_state + + +def generic_replicaset( + namespace: str, + version: str, + name: Optional[str] = None, + ops_manager: Optional[MongoDBOpsManager] = None, +) -> MongoDB: + if name is None: + name = KubernetesTester.random_k8s_name("rs-") + + rs = MongoDB(namespace=namespace, name=name) + rs["spec"] = { + "members": 3, + "type": "ReplicaSet", + "persistent": False, + "version": version, + } + + if ops_manager is None: + rs["spec"]["credentials"] = "my-credentials" + rs["spec"]["opsManager"] = {"configMapRef": {"name": "my-project"}} + else: + rs.configure(ops_manager, KubernetesTester.random_k8s_name("project-")) + + return rs diff --git a/docker/mongodb-enterprise-tests/kubetester/mongodb_multi.py b/docker/mongodb-enterprise-tests/kubetester/mongodb_multi.py new file mode 100644 index 000000000..f124d4f21 --- /dev/null +++ b/docker/mongodb-enterprise-tests/kubetester/mongodb_multi.py @@ -0,0 +1,101 @@ +from __future__ import annotations +from typing import Dict, List, Optional + +import kubernetes.client +from kubernetes import client +from kubetester import MongoDB +from kubetester.mongotester import MultiReplicaSetTester, MongoTester + + +class MultiClusterClient: + def __init__( + self, + api_client: kubernetes.client.ApiClient, + cluster_name: str, + cluster_index: int, + ): + self.api_client = api_client + self.cluster_name = cluster_name + self.cluster_index = cluster_index + + +class MongoDBMulti(MongoDB): + def __init__(self, *args, **kwargs): + with_defaults = { + "plural": "mongodbmulticluster", + "kind": "MongoDBMultiCluster", + "group": "mongodb.com", + "version": "v1", + } + with_defaults.update(kwargs) + super(MongoDBMulti, self).__init__(*args, **with_defaults) + + def read_statefulsets(self, clients: List[MultiClusterClient]) -> Dict[str, client.V1StatefulSet]: + statefulsets = {} + for mcc in clients: + statefulsets[mcc.cluster_name] = client.AppsV1Api(api_client=mcc.api_client).read_namespaced_stateful_set( + f"{self.name}-{mcc.cluster_index}", self.namespace + ) + return statefulsets + + def get_item_spec(self, cluster_name: str) -> Dict: + for spec in sorted( + self["spec"]["clusterSpecList"], + key=lambda x: x["clusterName"], + ): + if spec["clusterName"] == cluster_name: + return spec + + raise ValueError(f"Cluster with name {cluster_name} not found!") + + def read_services(self, clients: List[MultiClusterClient]) -> Dict[str, client.V1Service]: + services = {} + for mcc in clients: + spec = self.get_item_spec(mcc.cluster_name) + for (i, item) in enumerate(spec): + services[mcc.cluster_name] = client.CoreV1Api(api_client=mcc.api_client).read_namespaced_service( + f"{self.name}-{mcc.cluster_index}-{i}-svc", self.namespace + ) + return services + + def read_configmaps(self, clients: List[MultiClusterClient]) -> Dict[str, client.V1ConfigMap]: + configmaps = {} + for mcc in clients: + configmaps[mcc.cluster_name] = client.CoreV1Api(api_client=mcc.api_client).read_namespaced_config_map( + f"{self.name}-hostname-override", self.namespace + ) + return configmaps + + def service_names(self) -> List[str]: + # TODO: this function does not account for previous + # clusters being removed, the indices do not line up + # and as a result the incorrect service name will be returned. + service_names = [] + cluster_specs = sorted( + self["spec"]["clusterSpecList"], + key=lambda x: x["clusterName"], + ) + for (i, spec) in enumerate(cluster_specs): + for j in range(spec["members"]): + service_names.append(f"{self.name}-{i}-{j}-svc") + return service_names + + def tester( + self, + ca_path: Optional[str] = None, + srv: bool = False, + use_ssl: Optional[bool] = None, + service_names: Optional[List[str]] = None, + port="27017", + external: bool = False, + ) -> MongoTester: + if service_names is None: + service_names = self.service_names() + + return MultiReplicaSetTester( + service_names=service_names, + namespace=self.namespace, + port=port, + external=external + # TODO: tls, ca_path + ) diff --git a/docker/mongodb-enterprise-tests/kubetester/mongodb_user.py b/docker/mongodb-enterprise-tests/kubetester/mongodb_user.py new file mode 100644 index 000000000..2de1d6155 --- /dev/null +++ b/docker/mongodb-enterprise-tests/kubetester/mongodb_user.py @@ -0,0 +1,100 @@ +from __future__ import annotations +from typing import Optional, List +from dataclasses import dataclass + +from kubeobject import CustomObject +from kubetester.mongodb import MongoDB, MongoDBCommon, Phase, in_desired_state +from kubetester import random_k8s_name + + +class MongoDBUser(CustomObject, MongoDBCommon): + def __init__(self, *args, **kwargs): + with_defaults = { + "plural": "mongodbusers", + "kind": "MongoDBUser", + "group": "mongodb.com", + "version": "v1", + } + with_defaults.update(kwargs) + super(MongoDBUser, self).__init__(*args, **with_defaults) + + @property + def password(self): + return self._password + + def assert_reaches_phase( + self, phase: Phase, msg_regexp=None, timeout=None, ignore_errors=False + ): + return self.wait_for( + lambda s: in_desired_state( + current_state=self.get_status_phase(), + desired_state=phase, + current_generation=self.get_generation(), + observed_generation=self.get_status_observed_generation(), + current_message=self.get_status_message(), + msg_regexp=msg_regexp, + ignore_errors=ignore_errors, + ), + timeout, + should_raise=True, + ) + + def get_user_name(self): + return self["spec"]["username"] + + def get_secret_name(self) -> str: + return self["spec"]["passwordSecretKeyRef"]["name"] + + def get_status_phase(self) -> Optional[Phase]: + try: + return Phase[self["status"]["phase"]] + except KeyError: + return None + + def get_status_message(self) -> Optional[str]: + try: + return self["status"]["msg"] + except KeyError: + return None + + def get_status_observed_generation(self) -> Optional[int]: + try: + return self["status"]["observedGeneration"] + except KeyError: + return None + + def add_role(self, role: Role) -> MongoDBUser: + self["spec"]["roles"] = self["spec"].get("roles", []) + self["spec"]["roles"].append({"db": role.db, "name": role.role}) + + def add_roles(self, roles: List[Role]): + for role in roles: + self.add_role(role) + + +@dataclass(init=True) +class Role: + db: str + role: str + + +def generic_user( + namespace: str, + username: str, + db: str = "admin", + password: Optional[str] = None, + mongodb_resource: Optional[MongoDB] = None, +) -> MongoDBUser: + """Returns a generic User with a username and a pseudo-random k8s name.""" + user = MongoDBUser(name=random_k8s_name("user-"), namespace=namespace) + user["spec"] = { + "username": username, + "db": db, + } + + if mongodb_resource is not None: + user["spec"]["mongodbResourceRef"] = {"name": mongodb_resource.name} + + user._password = password + + return user diff --git a/docker/mongodb-enterprise-tests/kubetester/mongotester.py b/docker/mongodb-enterprise-tests/kubetester/mongotester.py new file mode 100644 index 000000000..604e3f1de --- /dev/null +++ b/docker/mongodb-enterprise-tests/kubetester/mongotester.py @@ -0,0 +1,641 @@ +import copy +import logging +import random +import ssl +import string +import threading +import time +from typing import Callable, List, Optional, Dict + +import pymongo +from kubetester import kubetester +from kubetester.kubetester import KubernetesTester +from pymongo.errors import OperationFailure, ServerSelectionTimeoutError, PyMongoError +from pytest import fail + +TEST_DB = "test-db" +TEST_COLLECTION = "test-collection" + + +def with_tls(use_tls: bool = False, ca_path: Optional[str] = None) -> Dict[str, str]: + # SSL is set to true by default if using mongodb+srv, it needs to be explicitely set to false + # https://docs.mongodb.com/manual/reference/program/mongo/index.html#cmdoption-mongo-host + options = {"ssl": use_tls} + + if use_tls: + options["ssl_ca_certs"] = kubetester.SSL_CA_CERT if ca_path is None else ca_path + return options + + +def with_scram(username: str, password: str, auth_mechanism: str = "SCRAM-SHA-256") -> Dict[str, str]: + valid_mechanisms = {"SCRAM-SHA-256", "SCRAM-SHA-1"} + if auth_mechanism not in valid_mechanisms: + raise ValueError(f"auth_mechanism must be one of {valid_mechanisms}, but was {auth_mechanism}.") + + return { + "authMechanism": auth_mechanism, + "password": password, + "username": username, + } + + +def with_x509(cert_file_name: str, ca_path: Optional[str] = None) -> Dict[str, str]: + options = with_tls(True, ca_path=ca_path) + options.update( + { + "authMechanism": "MONGODB-X509", + "ssl_certfile": cert_file_name, + "ssl_cert_reqs": ssl.CERT_REQUIRED, + } + ) + return options + + +def with_ldap(ssl_certfile: Optional[str] = None, ssl_ca_certs: Optional[str] = None) -> Dict[str, str]: + options = {} + if ssl_ca_certs is not None: + options.update(with_tls(True, ssl_ca_certs)) + if ssl_certfile is not None: + options["ssl_certfile"] = ssl_certfile + return options + + +class MongoTester: + """MongoTester is a general abstraction to work with mongo database. It incapsulates the client created in + the constructor. All general methods non-specific to types of mongodb topologies should reside here.""" + + def __init__( + self, + connection_string: str, + use_ssl: bool, + ca_path: Optional[str] = None, + ): + self.default_opts = with_tls(use_ssl, ca_path) + self.cnx_string = connection_string + self.client = None + + @property + def client(self): + if self._client is None: + self._client = self._init_client() + return self._client + + @client.setter + def client(self, value): + self._client = value + + def _merge_options(self, opts: List[Dict[str, str]]) -> Dict[str, str]: + options = copy.deepcopy(self.default_opts) + for opt in opts: + options.update(opt) + return options + + def _init_client(self, **kwargs): + return pymongo.MongoClient(self.cnx_string, **kwargs) + + def assert_connectivity( + self, + attempts: int = 20, + db: str = "admin", + col: str = "myCol", + opts: Optional[List[Dict[str, any]]] = None, + ): + if opts is None: + opts = [] + + options = self._merge_options(opts) + self.client = self._init_client(**options) + + assert attempts > 0 + while True: + attempts -= 1 + try: + self.client.admin.command("ismaster") + if "authMechanism" in options: + # Perform an action that will require auth. + self.client[db][col].insert_one({}) + except PyMongoError: + if attempts == 0: + raise + time.sleep(5) + else: + break + + def assert_no_connection(self, opts: Optional[List[Dict[str, str]]] = None): + try: + self.assert_connectivity(opts=opts) + fail() + except ServerSelectionTimeoutError: + pass + + def assert_version(self, expected_version: str): + # version field does not contain -ent suffix in MongoDB + assert self.client.admin.command("buildInfo")["version"] == expected_version.rstrip("-ent") + if expected_version.endswith("-ent"): + self.assert_is_enterprise() + + def assert_data_size(self, expected_count, test_collection=TEST_COLLECTION): + assert self.client[TEST_DB][test_collection].count() == expected_count + + def assert_is_enterprise(self): + assert "enterprise" in self.client.admin.command("buildInfo")["modules"] + + def assert_scram_sha_authentication( + self, + username: str, + password: str, + auth_mechanism: str, + attempts: int = 20, + ssl: bool = False, + **kwargs, + ) -> None: + assert attempts > 0 + assert auth_mechanism in {"SCRAM-SHA-256", "SCRAM-SHA-1"} + + for i in reversed(range(attempts)): + try: + self._authenticate_with_scram( + username, + password, + auth_mechanism=auth_mechanism, + ssl=ssl, + **kwargs, + ) + return + except OperationFailure as e: + if i == 0: + fail(msg=f"unable to authenticate after {attempts} attempts with error: {e}") + time.sleep(5) + + def assert_scram_sha_authentication_fails( + self, + username: str, + password: str, + retries: int = 20, + ssl: bool = False, + **kwargs, + ): + """ + If a password has changed, it could take some time for the user changes to propagate, meaning + this could return true if we make a CRD change and immediately try to auth as the old user + which still exists. When we change a password, we should eventually no longer be able to auth with + that user's credentials. + """ + for i in range(retries): + try: + self._authenticate_with_scram(username, password, ssl=ssl, **kwargs) + except OperationFailure: + return + time.sleep(5) + fail(f"was still able to authenticate with username={username} password={password} after {retries} attempts") + + def _authenticate_with_scram( + self, + username: str, + password: str, + auth_mechanism: str, + ssl: bool = False, + **kwargs, + ): + + options = self._merge_options( + [ + with_tls(ssl, ca_path=kwargs.get("ssl_ca_certs")), + with_scram(username, password, auth_mechanism), + ] + ) + + self.client = self._init_client(**options) + # authentication doesn't actually happen until we interact with a database + self.client["admin"]["myCol"].insert_one({}) + + def assert_x509_authentication(self, cert_file_name: str, attempts: int = 20, **kwargs): + assert attempts > 0 + + options = self._merge_options( + [ + with_x509(cert_file_name, kwargs.get("ssl_ca_certs", kubetester.SSL_CA_CERT)), + ] + ) + + total_attempts = attempts + while True: + attempts -= 1 + try: + self.client = self._init_client(**options) + self.client["admin"]["myCol"].insert_one({}) + return + except OperationFailure: + if attempts == 0: + fail(msg=f"unable to authenticate after {total_attempts} attempts") + time.sleep(5) + + def assert_ldap_authentication( + self, + username: str, + password: str, + db: str = "admin", + collection: str = "myCol", + ssl_ca_certs: Optional[str] = None, + ssl_certfile: str = None, + attempts: int = 20, + ): + + options = with_ldap(ssl_certfile, ssl_ca_certs) + total_attempts = attempts + + while True: + attempts -= 1 + try: + client = self._init_client(**options) + client.admin.authenticate(username, password, source="$external", mechanism="PLAIN") + + client[db][collection].insert_one({"data": "I need to exist!"}) + + return + except OperationFailure: + if attempts <= 0: + fail(msg=f"unable to authenticate after {total_attempts} attempts") + time.sleep(5) + + def upload_random_data( + self, + count: int, + generation_function: Optional[Callable] = None, + ): + return upload_random_data(self.client, count, generation_function) + + def assert_deployment_reachable(self, attempts: int = 5): + """See: https://jira.mongodb.org/browse/CLOUDP-68873 + the agents might report being in goal state, the MDB resource + would report no errors but the deployment would be unreachable + The workaround is to use the public API to get the list of + hosts and check the typeName field of each host. + This would be NO_DATA if the hosts are not reachable + See docs: https://docs.opsmanager.mongodb.com/current/reference/api/hosts/get-all-hosts-in-group/#response-document + at the "typeName" field + """ + while True: + hosts_unreachable = 0 + attempts -= 1 + hosts = KubernetesTester.get_hosts() + print(f"hosts: {hosts}") + for host in hosts["results"]: + print(f"current host: {host}") + if host["typeName"] == "NO_DATA": + hosts_unreachable += 1 + if hosts_unreachable == 0: + return + if attempts <= 0: + fail(msg="Some hosts still report NO_DATA state") + time.sleep(5) + + +class StandaloneTester(MongoTester): + def __init__( + self, + mdb_resource_name: str, + ssl: bool = False, + ca_path: Optional[str] = None, + namespace: Optional[str] = None, + port="27017", + external_domain: Optional[str] = None, + ): + if namespace is None: + namespace = KubernetesTester.get_namespace() + + self.cnx_string = build_mongodb_connection_uri( + mdb_resource_name, namespace, 1, port, external_domain=external_domain + ) + super().__init__(self.cnx_string, ssl, ca_path) + + +class ReplicaSetTester(MongoTester): + def __init__( + self, + mdb_resource_name: str, + replicas_count: int, + ssl: bool = False, + srv: bool = False, + ca_path: Optional[str] = None, + namespace: Optional[str] = None, + port="27017", + external_domain: Optional[str] = None, + ): + if namespace is None: + # backward compatibility with docstring tests + namespace = KubernetesTester.get_namespace() + + self.replicas_count = replicas_count + + self.cnx_string = build_mongodb_connection_uri( + mdb_resource_name, + namespace, + replicas_count, + servicename=None, + srv=srv, + port=port, + external_domain=external_domain, + ) + + super().__init__(self.cnx_string, ssl, ca_path) + + def assert_connectivity( + self, + wait_for=60, + check_every=5, + with_srv=False, + attempts: int = 5, + opts: Optional[List[Dict[str, str]]] = None, + ): + """For replica sets in addition to is_master() we need to make sure all replicas are up""" + super().assert_connectivity(attempts=attempts, opts=opts) + + if self.replicas_count == 1: + # On 1 member replica-set, there won't be a "primary" and secondaries will be `set()` + assert self.client.primary is None + assert len(self.client.secondaries) == 0 + return + + check_times = wait_for // check_every + + while ( + self.client.primary is None or len(self.client.secondaries) < self.replicas_count - 1 + ) and check_times >= 0: + time.sleep(check_every) + check_times -= 1 + + assert self.client.primary is not None + assert len(self.client.secondaries) == self.replicas_count - 1 + + +class MultiReplicaSetTester(MongoTester): + def __init__( + self, + service_names: List[str], + port: str, + namespace: Optional[str] = None, + external: bool = False, + ): + super().__init__( + build_mongodb_multi_connection_uri(namespace, service_names, port, external=external), + use_ssl=False, + ca_path=None, + ) + + +class ShardedClusterTester(MongoTester): + def __init__( + self, + mdb_resource_name: str, + mongos_count: int, + ssl: bool = False, + srv: bool = False, + ca_path: Optional[str] = None, + namespace: Optional[str] = None, + port="27017", + ): + mdb_name = mdb_resource_name + "-mongos" + servicename = mdb_resource_name + "-svc" + + if namespace is None: + # backward compatibility with docstring tests + namespace = KubernetesTester.get_namespace() + + self.cnx_string = build_mongodb_connection_uri( + mdb_name, + namespace, + mongos_count, + port=port, + servicename=servicename, + srv=srv, + ) + super().__init__(self.cnx_string, ssl, ca_path) + + def shard_collection(self, shards_pattern, shards_count, key, test_collection=TEST_COLLECTION): + """enables sharding and creates zones to make sure data is spread over shards. + Assumes that the documents have field 'key' with value in [0,10] range""" + for i in range(shards_count): + self.client.admin.command("addShardToZone", shards_pattern.format(i), zone="zone-{}".format(i)) + + for i in range(shards_count): + self.client.admin.command( + "updateZoneKeyRange", + db_namespace(test_collection), + min={key: i * (10 / shards_count)}, + max={key: (i + 1) * (10 / shards_count)}, + zone="zone-{}".format(i), + ) + + self.client.admin.command("enableSharding", TEST_DB) + self.client.admin.command("shardCollection", db_namespace(test_collection), key={key: 1}) + + def prepare_for_shard_removal(self, shards_pattern, shards_count): + """We need to map all the shards to all the zones to let shard be removed (otherwise the balancer gets + stuck as it cannot move chunks from shards being removed)""" + for i in range(shards_count): + for j in range(shards_count): + self.client.admin.command("addShardToZone", shards_pattern.format(i), zone="zone-{}".format(j)) + + def assert_number_of_shards(self, expected_count): + assert len(self.client.admin.command("listShards")["shards"]) == expected_count + + +class BackgroundHealthChecker(threading.Thread): + """BackgroundHealthChecker is the thread which periodically calls the function to check health of some resource. It's + run as a daemon so usually there's no need in stopping it manually. + """ + + def __init__( + self, + health_function, + wait_sec: int = 3, + allowed_sequential_failures: int = 3, + health_function_params=None, + ): + super().__init__() + if health_function_params is None: + health_function_params = {} + self._stop_event = threading.Event() + self.health_function = health_function + self.health_function_params = health_function_params + self.wait_sec = wait_sec + self.allowed_sequential_failures = allowed_sequential_failures + self.exception_number = 0 + self.last_exception = None + self.daemon = True + self.max_consecutive_failure = 0 + self.number_of_runs = 0 + + def run(self): + consecutive_failure = 0 + while not self._stop_event.isSet(): + self.number_of_runs += 1 + try: + self.health_function(**self.health_function_params) + consecutive_failure = 0 + except Exception as e: + print(f"Error in {self.__class__.__name__}: {e})") + self.last_exception = e + consecutive_failure = consecutive_failure + 1 + self.max_consecutive_failure = max(self.max_consecutive_failure, consecutive_failure) + self.exception_number = self.exception_number + 1 + time.sleep(self.wait_sec) + + def stop(self): + self._stop_event.set() + + def assert_healthiness(self, allowed_rate_of_failure: Optional[float] = None): + """ + + `allowed_rate_of_failure` allows you to define a rate of allowed failures, + instead of the default, absolute amount of failures. + + `allowed_rate_of_failure` is a number between 0 and 1 that desribes a "percentage" + of tolerated failures. + + For instance, the following values: + + - 0.1 -- means that 10% of the requests might fail, before + failing the tests. + - 0.9 -- 90% of checks are allowed to fail. + - 0.0 -- very strict: no checks are allowed to fail. + - 1.0 -- very relaxed: all checks can fail. + + """ + print("\nlongest consecutive failures: {}".format(self.max_consecutive_failure)) + print("total exceptions count: {}".format(self.exception_number)) + print("total checks number: {}".format(self.number_of_runs)) + + allowed_failures = self.allowed_sequential_failures + if allowed_rate_of_failure is not None: + allowed_failures = self.number_of_runs * allowed_rate_of_failure + + assert self.max_consecutive_failure <= allowed_failures + assert self.number_of_runs > 0 + + +class MongoDBBackgroundTester(BackgroundHealthChecker): + def __init__( + self, + mongo_tester: MongoTester, + wait_sec: int = 3, + allowed_sequential_failures: int = 1, + ): + super().__init__( + health_function=mongo_tester.assert_connectivity, + health_function_params={"attempts": 1}, + wait_sec=wait_sec, + allowed_sequential_failures=allowed_sequential_failures, + ) + + +def build_mongodb_connection_uri( + mdb_resource: str, + namespace: str, + members: int, + port: str, + servicename: str = None, + srv: bool = False, + external_domain: str = None, +) -> str: + if servicename is None: + servicename = "{}-svc".format(mdb_resource) + + if external_domain: + return build_mongodb_uri(build_list_of_hosts_with_external_domain(mdb_resource, members, external_domain, port)) + if srv: + return build_mongodb_uri(build_host_srv(servicename, namespace), srv) + else: + return build_mongodb_uri(build_list_of_hosts(mdb_resource, namespace, members, servicename, port)) + + +def build_mongodb_multi_connection_uri( + namespace: str, service_names: List[str], port: str, external: bool = False +) -> str: + return build_mongodb_uri(build_list_of_multi_hosts(namespace, service_names, port, external=external)) + + +def build_list_of_hosts(mdb_resource: str, namespace: str, members: int, servicename: str, port: str) -> List[str]: + return [build_host_fqdn("{}-{}".format(mdb_resource, idx), namespace, servicename, port) for idx in range(members)] + + +def build_list_of_hosts_with_external_domain( + mdb_resource: str, members: int, external_domain: str, port: str +) -> List[str]: + return [f"{mdb_resource}-{idx}.{external_domain}:{port}" for idx in range(members)] + + +def build_list_of_multi_hosts(namespace: str, service_names: List[str], port, external: bool = False) -> List[str]: + if external: + return [f"{service_name}:{port}" for service_name in service_names] + return [build_host_service_fqdn(namespace, service_name, port) for service_name in service_names] + + +def build_host_service_fqdn(namespace: str, servicename: str, port) -> str: + return f"{servicename}.{namespace}.svc.cluster.local:{port}" + + +def build_host_fqdn(hostname: str, namespace: str, servicename: str, port) -> str: + return "{hostname}.{servicename}.{namespace}.svc.cluster.local:{port}".format( + hostname=hostname, servicename=servicename, namespace=namespace, port=port + ) + + +def build_host_srv(servicename: str, namespace: str) -> str: + srv_host = "{servicename}.{namespace}.svc.cluster.local".format(servicename=servicename, namespace=namespace) + return srv_host + + +def build_mongodb_uri(hosts, srv: bool = False) -> str: + plus_srv = "" + if srv: + plus_srv = "+srv" + else: + hosts = ",".join(hosts) + + return "mongodb{}://{}".format(plus_srv, hosts) + + +def generate_single_json(): + """Generates a json with two fields. String field contains random characters and has length 100 characters.""" + random_str = "".join([random.choice(string.ascii_lowercase) for _ in range(100)]) + return {"description": random_str, "type": random.uniform(1, 10)} + + +def db_namespace(collection): + """https://docs.mongodb.com/manual/reference/glossary/#term-namespace""" + return "{}.{}".format(TEST_DB, collection) + + +def upload_random_data( + client, + count: int, + generation_function: Optional[Callable] = None, + task_name: Optional[str] = "default", +): + """ + Generates random json documents and uploads them to database. This data can + be later checked for integrity. + """ + + if generation_function is None: + generation_function = generate_single_json + + logging.info("task: {}. Inserting {} fake records to {}.{}".format(task_name, count, TEST_DB, TEST_COLLECTION)) + + target = client[TEST_DB][TEST_COLLECTION] + buf = [] + + for a in range(count): + buf.append(generation_function()) + if len(buf) == 1_000: + target.insert_many(buf) + buf.clear() + if (a + 1) % 10_000 == 0: + logging.info("task: {}. Inserted {} document".format(task_name, a + 1)) + # tail + if len(buf) > 0: + target.insert_many(buf) + + logging.info("task: {}. Task finished, {} documents inserted. ".format(task_name, count)) diff --git a/docker/mongodb-enterprise-tests/kubetester/om_queryable_backups.py b/docker/mongodb-enterprise-tests/kubetester/om_queryable_backups.py new file mode 100644 index 000000000..7b74f295b --- /dev/null +++ b/docker/mongodb-enterprise-tests/kubetester/om_queryable_backups.py @@ -0,0 +1,207 @@ +import requests +import time +from html.parser import HTMLParser +from dataclasses import dataclass +import subprocess +import logging +import tempfile + + +@dataclass(init=True) +class QueryableBackupParams: + host: str + ca_pem: str + client_pem: str + + +class CsrfHtmlParser(HTMLParser): + def parse_html(self, html): + self._csrf_fields = dict({}) + self.feed(html) + return self._csrf_fields + + def handle_starttag(self, tag, attrs): + if tag == "meta": + attr_name = next((a[1] for a in attrs if a[0] == "name"), None) + attr_content = next((a[1] for a in attrs if a[0] == "content"), None) + if attr_name is not None and attr_name.startswith("csrf"): + self._csrf_fields[attr_name] = attr_content + + +class OMQueryableBackup: + def __init__(self, om_url, project_id): + self._om_url = om_url + self._project_id = project_id + + def _login(self): + endpoint = f"{self._om_url}/user/v1/auth" + headers = {"Content-Type": "application/json"} + data = { + "username": "jane.doe@example.com", + "password": "Passw0rd.", + "reCaptchaResponse": None, + } + + response = requests.post(endpoint, json=data, headers=headers) + if response.status_code != 200: + raise Exception( + f"OM login failed with status code: {response.status_code}, content: {response.content}" + ) + + self._auth_cookies = response.cookies + + def _authenticated_http_get(self, url, headers=None): + response = requests.get(url, headers=headers or {}, cookies=self._auth_cookies) + if response.status_code != 200: + raise Exception( + f"HTTP GET failed with status code: {response.status_code}, content: {response.content}" + ) + return response + + def _get_snapshots_query_host(self): + return ( + self._authenticated_http_get( + f"{self._om_url}/v2/{self._project_id}/params", + headers={"Accept": "application/json"}, + ) + .json() + .get("snapshotsQueryHost") + ) + + def _get_first_snapshot_id(self): + return ( + self._authenticated_http_get( + f"{self._om_url}/backup/web/snapshot/{self._project_id}/mdb-four-two", + headers={"Accept": "application/json"}, + ) + .json() + .get("entries")[0] + .get("id") + ) + + def _get_csrf_headers(self): + html_response = self._authenticated_http_get( + f"{self._om_url}/v2/{self._project_id}" + ).text + csrf_fields = CsrfHtmlParser().parse_html(html_response) + return {f"x-{k}": v for k, v in csrf_fields.items()} + + def _start_query_backup(self, first_snapshot_id, csrf_headers): + response = requests.put( + f"{self._om_url}/backup/web/restore/{self._project_id}/query/{first_snapshot_id}", + headers=csrf_headers, + cookies=self._auth_cookies, + ) + return response.json().get("snapshotQueryId") + + def _download_client_cert(self, snapshot_query_id): + return self._authenticated_http_get( + f"{self._om_url}/backup/web/restore/{self._project_id}/query/{snapshot_query_id}/keypair" + ).text + + def _download_ca(self): + return self._authenticated_http_get( + f"{self._om_url}/backup/web/restore/{self._project_id}/query/ca" + ).text + + def _wait_until_ready_to_query(self, timeout: int): + initial_timeout = timeout + ready_status = "waitingForCustomer" + while timeout > 0: + restoreEntries = self._authenticated_http_get( + f"{self._om_url}/v2/backup/restore/{self._project_id}", + headers={"Accept": "application/json"}, + ).json() + + if ( + len(restoreEntries) > 0 + and restoreEntries[0].get("progressPhase") == ready_status + ): + return + + time.sleep(3) + timeout -= 3 + + raise Exception( + f"Timeout ({initial_timeout}) reached while waiting for '{ready_status}' snapshot query status" + ) + + def connection_params(self, timeout: int): + """Retrieves the connection config (host, ca / client pem files) used to query a backup snapshot.""" + self._login() + + first_snapshot_id = self._get_first_snapshot_id() + + csrf_headers = self._get_csrf_headers() + + snapshot_query_id = self._start_query_backup(first_snapshot_id, csrf_headers) + + self._wait_until_ready_to_query(timeout) + + return QueryableBackupParams( + host=self._get_snapshots_query_host(), + ca_pem=self._download_ca(), + client_pem=self._download_client_cert(snapshot_query_id), + ) + + +def generate_queryable_pem(namespace: str): + # todo: investigate if cert-manager can be used instead of openssl + openssl_conf = f""" +prompt=no +distinguished_name = qb_req_distinguished_name + +x509_extensions = qb_extensions + +[qb_req_distinguished_name] +C=US +ST=New York +L=New York +O=MongoDB, Inc. +CN=queryable-backup-test.mongodb.com + +[qb_extensions] +basicConstraints=CA:true +subjectAltName=@qb_subject_alt_names + +[qb_subject_alt_names] +DNS.1 = om-backup-svc.{namespace}.svc.cluster.local +""" + + openssl_conf_file = tempfile.NamedTemporaryFile(delete=False, mode="w") + openssl_conf_file.write(openssl_conf) + openssl_conf_file.flush() + + csr_file_path = "/tmp/queryable-backup.csr" + key_file_path = "/tmp/queryable-backup.key" + + args = [ + "openssl", + "req", + "-new", + "-x509", + "-days", + "824", + "-nodes", + "-out", + csr_file_path, + "-newkey", + "rsa:2048", + "-keyout", + key_file_path, + "-config", + openssl_conf_file.name, + ] + + try: + completed_process = subprocess.run(args, capture_output=True) + completed_process.check_returncode() + except subprocess.CalledProcessError as exc: + stdout = exc.stdout.decode("utf-8") + stderr = exc.stderr.decode("utf-8") + logging.info(stdout) + logging.info(stderr) + raise + + with open(csr_file_path, "r") as csr_file, open(key_file_path, "r") as key_file: + return f"{csr_file.read()}\n{key_file.read()}" diff --git a/docker/mongodb-enterprise-tests/kubetester/omtester.py b/docker/mongodb-enterprise-tests/kubetester/omtester.py new file mode 100644 index 000000000..fd4d90bd1 --- /dev/null +++ b/docker/mongodb-enterprise-tests/kubetester/omtester.py @@ -0,0 +1,633 @@ +from __future__ import annotations + +import logging +import re +import time +import urllib.parse +from datetime import datetime +from enum import Enum +from typing import Dict, List, Optional + +import pytest +import requests +import semver +import pymongo +import tempfile + +from kubetester.automation_config_tester import AutomationConfigTester +from kubetester.kubetester import build_auth +from kubetester.mongotester import BackgroundHealthChecker +from kubetester.om_queryable_backups import OMQueryableBackup + +from .kubetester import get_env_var_or_fail + + +def running_cloud_manager(): + "Determines if the current test is running against Cloud Manager" + return get_env_var_or_fail("OM_HOST") == "https://cloud-qa.mongodb.com" + + +skip_if_cloud_manager = pytest.mark.skipif(running_cloud_manager(), reason="Do not run in Cloud Manager") + + +class BackupStatus(str, Enum): + """Enum for backup statuses in Ops Manager. Note that 'str' is inherited to fix json serialization issues""" + + STARTED = "STARTED" + STOPPED = "STOPPED" + TERMINATING = "TERMINATING" + + +# todo use @dataclass annotation https://www.python.org/dev/peps/pep-0557/ +class OMContext(object): + def __init__( + self, + base_url, + user, + public_key, + project_name=None, + project_id=None, + org_id=None, + ): + self.base_url = base_url + self.project_id = project_id + self.group_name = project_name + self.user = user + self.public_key = public_key + self.org_id = org_id + + @staticmethod + def build_from_config_map_and_secret( + connection_config_map: Dict[str, str], connection_secret: Dict[str, str] + ) -> OMContext: + if "publicApiKey" in connection_secret: + return OMContext( + base_url=connection_config_map["baseUrl"], + project_id=None, + project_name=connection_config_map["projectName"], + org_id=connection_config_map.get("orgId", ""), + user=connection_secret["user"], + public_key=connection_secret["publicApiKey"], + ) + else: + return OMContext( + base_url=connection_config_map["baseUrl"], + project_id=None, + project_name=connection_config_map["projectName"], + org_id=connection_config_map.get("orgId", ""), + user=connection_secret["publicKey"], + public_key=connection_secret["privateKey"], + ) + + +class OMTester(object): + """OMTester is designed to encapsulate communication with Ops Manager. It also provides the + set of assertion methods helping to write tests""" + + def __init__(self, om_context: OMContext): + self.context = om_context + # we only have a group id if we also have a name + if self.context.group_name: + self.ensure_group_id() + + def ensure_group_id(self): + if self.context.project_id is None: + self.context.project_id = self.find_group_id() + + def create_restore_job_snapshot(self, snapshot_id: Optional[str] = None) -> str: + """restores the mongodb cluster to some version using the snapshot. If 'snapshot_id' omitted then the + latest snapshot will be used.""" + cluster_id = self.get_backup_cluster_id() + if snapshot_id is None: + snapshots = self.api_get_snapshots(cluster_id) + snapshot_id = snapshots[-1]["id"] + + return self.api_create_restore_job_from_snapshot(cluster_id, snapshot_id)["id"] + + def create_restore_job_pit(self, pit_milliseconds: int, retry: int = 120): + """creates a restore job to restore the mongodb cluster to some version specified by the parameter.""" + cluster_id = self.get_backup_cluster_id() + + while retry > 0: + try: + self.api_create_restore_job_pit(cluster_id, pit_milliseconds) + return + except Exception as e: + # this exception is usually raised for some time (some oplog slices not received or whatever) + # but eventually is gone and restore job is started.. + if "Invalid restore point:" not in str(e): + raise e + retry -= 1 + time.sleep(1) + raise Exception("Failed to create a restore job!") + + def wait_until_backup_snapshots_are_ready( + self, expected_count: int, timeout: int = 1500, expected_config_count: int = 1 + ): + """waits until at least 'expected_count' backup snapshots is in complete state""" + start_time = time.time() + cluster_id = self.get_backup_cluster_id(expected_config_count) + + if expected_count == 1: + print(f"Waiting until 1 snapshot is ready (can take a while)") + else: + print(f"Waiting until {expected_count} snapshots are ready (can take a while)") + + initial_timeout = timeout + while timeout > 0: + snapshots = self.api_get_snapshots(cluster_id) + if len([s for s in snapshots if s["complete"]]) >= expected_count: + print(f"Snapshots are ready, project: {self.context.group_name}, time: {time.time() - start_time} sec") + return + time.sleep(3) + timeout -= 3 + + snapshots = self.api_get_snapshots(cluster_id) + print(f"Current Backup Snapshots: {snapshots}") + + raise Exception( + f"Timeout ({initial_timeout}) reached while waiting for {expected_count} snapshot(s) to be ready for the " + f"project {self.context.group_name} " + ) + + def wait_until_restore_job_is_ready(self, job_id: str, timeout: int = 1500): + """waits until there's one finished restore job in the project""" + start_time = time.time() + cluster_id = self.get_backup_cluster_id() + + print(f"Waiting until restore job with id {job_id} is finished") + + initial_timeout = timeout + while timeout > 0: + job = self.api_get_restore_job_by_id(cluster_id, job_id) + if job["statusName"] == "FINISHED": + print( + f"Restore job is finished, project: {self.context.group_name}, time: {time.time() - start_time} sec" + ) + return + time.sleep(3) + timeout -= 3 + + jobs = self.api_get_restore_jobs(cluster_id) + print(f"Current Restore Jobs: {jobs}") + + raise AssertionError( + f"Timeout ({initial_timeout}) reached while waiting for the restore job to finish for the " + f"project {self.context.group_name} " + ) + + def get_backup_cluster_id(self, expected_config_count: int = 1) -> str: + configs = self.api_read_backup_configs() + assert len(configs) == expected_config_count + # we can use the first config as there's only one MongoDB in deployment + return configs[0]["clusterId"] + + def assert_healthiness(self): + self.do_assert_healthiness(self.context.base_url) + # TODO we need to check the login page as well (/user) - does it render properly? + + def assert_om_instances_healthiness(self, pod_urls: str): + """Checks each of the OM urls for healthiness. This is different from 'assert_healthiness' which makes + a call to the service instead""" + for pod_fqdn in pod_urls: + self.do_assert_healthiness(pod_fqdn) + + def assert_version(self, version: str): + """makes the request to a random API url to get headers""" + response = self.om_request("get", "/orgs") + assert f"versionString={version}" in response.headers["X-MongoDB-Service-Version"] + + def assert_test_service(self): + endpoint = self.context.base_url + "/test/utils/systemTime" + response = requests.request("get", endpoint, verify=False) + assert response.status_code == requests.status_codes.codes.OK + + def assert_support_page_enabled(self): + """The method ends successfully if 'mms.helpAndSupportPage.enabled' is set to 'true'. It's 'false' by default. + See mms SupportResource.supportLoggedOut()""" + endpoint = self.context.base_url + "/support" + response = requests.request("get", endpoint, allow_redirects=False, verify=False) + + # logic: if mms.helpAndSupportPage.enabled==true - then status is 307, otherwise 303" + assert response.status_code == 307 + + def assert_group_exists(self): + path = "/groups/" + self.context.project_id + response = self.om_request("get", path) + + assert response.status_code == requests.status_codes.codes.OK + + def assert_daemon_enabled(self, host_fqdn: str, head_db_path: str): + encoded_head_db_path = urllib.parse.quote(head_db_path, safe="") + response = self.om_request( + "get", + f"/admin/backup/daemon/configs/{host_fqdn}/{encoded_head_db_path}", + ) + + assert response.status_code == requests.status_codes.codes.OK + daemon_config = response.json() + assert daemon_config["machine"] == { + "headRootDirectory": head_db_path, + "machine": host_fqdn, + } + assert daemon_config["assignmentEnabled"] + assert daemon_config["configured"] + + def _assert_stores(self, expected_stores: List[Dict], endpoint: str, store_type: str): + response = self.om_request("get", endpoint) + assert response.status_code == requests.status_codes.codes.OK + + existing_stores = {result["id"]: result for result in response.json()["results"]} + + assert len(expected_stores) == len(existing_stores), f"expected:{expected_stores} actual: {existing_stores}." + + for expected in expected_stores: + store_id = expected["id"] + assert store_id in existing_stores, f"existing {store_type} store with id {store_id} not found" + existing = existing_stores[store_id] + for key in expected: + assert expected[key] == existing[key] + + def assert_oplog_stores(self, expected_oplog_stores: List): + """verifies that the list of oplog store configs in OM is equal to the expected one""" + self._assert_stores(expected_oplog_stores, "/admin/backup/oplog/mongoConfigs", "oplog") + + def assert_oplog_s3_stores(self, expected_oplog_s3_stores: List): + """verifies that the list of oplog s3 store configs in OM is equal to the expected one""" + self._assert_stores(expected_oplog_s3_stores, "/admin/backup/oplog/s3Configs", "s3") + + def assert_block_stores(self, expected_block_stores: List): + """verifies that the list of oplog store configs in OM is equal to the expected one""" + self._assert_stores(expected_block_stores, "/admin/backup/snapshot/mongoConfigs", "blockstore") + + def assert_s3_stores(self, expected_s3_stores: List): + """verifies that the list of s3 store configs in OM is equal to the expected one""" + self._assert_stores(expected_s3_stores, "/admin/backup/snapshot/s3Configs", "s3") + + def assert_hosts_empty(self): + self.get_automation_config_tester().assert_empty() + hosts = self.api_get_hosts() + assert len(hosts["results"]) == 0 + + def assert_om_version(self, expected_version: str): + assert self.api_get_om_version() == expected_version + + def check_healthiness(self) -> (str, str): + return OMTester.request_health(self.context.base_url) + + @staticmethod + def request_health(base_url: str) -> (str, str): + endpoint = base_url + "/monitor/health" + response = requests.request("get", endpoint, verify=False) + return response.status_code, response.text + + @staticmethod + def do_assert_healthiness(base_url: str): + status_code, _ = OMTester.request_health(base_url) + assert ( + status_code == requests.status_codes.codes.OK + ), "Expected HTTP 200 from Ops Manager but got {} ({})".format(status_code, datetime.now()) + + def om_request(self, method, path, json_object: Optional[Dict] = None): + """performs the digest API request to Ops Manager. Note that the paths don't need to be prefixed with + '/api../v1.0' as the method does it internally.""" + headers = {"Content-Type": "application/json"} + auth = build_auth(self.context.user, self.context.public_key) + + endpoint = f"{self.context.base_url}/api/public/v1.0{path}" + response = requests.request( + method, + endpoint, + auth=auth, + headers=headers, + json=json_object, + verify=False, + ) + + if response.status_code >= 300: + raise Exception( + "Error sending request to Ops Manager API. {} ({}).\n Request details: {} {} (data: {})".format( + response.status_code, response.text, method, endpoint, json_object + ) + ) + + return response + + def get_feature_controls(self): + return self.om_request("get", f"/groups/{self.context.project_id}/controlledFeature").json() + + def find_group_id(self): + """ + Obtains the group id of the group with specified name. + Note, that the logic used repeats the logic used by the Operator. + """ + if self.context.org_id is None or self.context.org_id == "": + # If no organization is passed, then look for all organizations + self.context.org_id = self.api_get_organization_id(self.context.group_name) + if self.context.org_id == "": + raise Exception(f"Organization with name {self.context.group_name} not found!") + + group_id = self.api_get_group_in_organization(self.context.org_id, self.context.group_name) + if group_id == "": + raise Exception( + f"Group with name {self.context.group_name} not found in organization {self.context.org_id}!" + ) + return group_id + + def api_backup_group(self): + group_id = self.find_group_id() + return self.om_request("get", f"/admin/backup/groups/{self.context.project_id}").json() + + def api_get_om_version(self) -> str: + # This can be any API request - we just need the header in the response + response = self.om_request("get", f"/groups/{self.context.project_id}/backupConfigs") + version_header = response.headers["X-MongoDB-Service-Version"] + version = version_header.split("versionString=")[1] + parsed_version = semver.VersionInfo.parse(version) + return f"{parsed_version.major}.{parsed_version.minor}.{parsed_version.patch}" + + def api_get_organization_id(self, org_name: str) -> str: + encoded_org_name = urllib.parse.quote_plus(org_name) + json = self.om_request("get", f"/orgs?name={encoded_org_name}").json() + if len(json["results"]) == 0: + return "" + return json["results"][0]["id"] + + def api_get_group_in_organization(self, org_id: str, group_name: str) -> str: + encoded_group_name = urllib.parse.quote_plus(group_name) + json = self.om_request("get", f"/orgs/{org_id}/groups?name={encoded_group_name}").json() + if len(json["results"]) == 0: + return "" + if len(json["results"]) > 1: + raise Exception(f"More than one groups with name {group_name} found!") + return json["results"][0]["id"] + + def api_get_hosts(self) -> Dict: + return self.om_request("get", f"/groups/{self.context.project_id}/hosts").json() + + def get_automation_config_tester(self, **kwargs) -> AutomationConfigTester: + json = self.om_request("get", f"/groups/{self.context.project_id}/automationConfig").json() + return AutomationConfigTester(json, **kwargs) + + def api_read_backup_configs(self) -> List: + return self.om_request("get", f"/groups/{self.context.project_id}/backupConfigs").json()["results"] + + def api_read_backup_snapshot_schedule(self) -> Dict: + backup_configs = self.api_read_backup_configs()[0] + return self.om_request( + "get", + f"/groups/{self.context.project_id}/backupConfigs/{backup_configs['clusterId']}/snapshotSchedule", + ).json() + + def api_read_monitoring_measurements( + self, + host_id: str, + database_name: Optional[str] = None, + project_id: Optional[str] = None, + period: str = "P1DT12H", + ): + """ + Reads a measurement from the measurements and alerts API: + + https://docs.opsmanager.mongodb.com/v4.4/reference/api/measures/get-host-process-system-measurements/ + """ + if database_name is None: + database_name = "admin" + return self.om_request( + "get", + f"/groups/{project_id}/hosts/{host_id}/databases/{database_name}/measurements?granularity=PT30S&period={period}", + ).json()["measurements"] + + def api_read_monitoring_agents(self) -> List: + return self._read_agents("MONITORING") + + def api_read_automation_agents(self) -> List: + return self._read_agents("AUTOMATION") + + def _read_agents(self, agent_type: str, page_num: int = 1): + return self.om_request( + "get", + f"/groups/{self.context.project_id}/agents/{agent_type}?pageNum={page_num}", + ).json()["results"] + + def api_get_snapshots(self, cluster_id: str) -> List: + return self.om_request("get", f"/groups/{self.context.project_id}/clusters/{cluster_id}/snapshots").json()[ + "results" + ] + + def api_create_restore_job_pit(self, cluster_id: str, pit_milliseconds: int): + """Creates a restore job that reverts a mongodb cluster to some time defined by 'pit_milliseconds'""" + data = self._restore_job_payload(cluster_id) + data["pointInTimeUTCMillis"] = pit_milliseconds + return self.om_request( + "post", + f"/groups/{self.context.project_id}/clusters/{cluster_id}/restoreJobs", + data, + ) + + def api_create_restore_job_from_snapshot(self, cluster_id: str, snapshot_id: str, retry: int = 3) -> Dict: + """ + Creates a restore job that uses an existing snapshot as the source + + The restore job might fail to be created if + """ + data = self._restore_job_payload(cluster_id) + data["snapshotId"] = snapshot_id + + for r in range(retry): + try: + result = self.om_request( + "post", + f"/groups/{self.context.project_id}/clusters/{cluster_id}/restoreJobs", + data, + ) + except Exception as e: + logging.info(e) + logging.info(f"Could not create restore job, attempt {r + 1}") + time.sleep((r + 1) * 10) + continue + + return result.json()["results"][0] + + raise Exception(f"Could not create restore job after {retry} attempts") + + def api_get_restore_jobs(self, cluster_id: str) -> List: + return self.om_request( + "get", + f"/groups/{self.context.project_id}/clusters/{cluster_id}/restoreJobs", + ).json()["results"] + + def api_get_restore_job_by_id(self, cluster_id: str, id: str) -> Dict: + return self.om_request( + "get", + f"/groups/{self.context.project_id}/clusters/{cluster_id}/restoreJobs/{id}", + ).json() + + def api_remove_group(self): + return self.om_request("delete", f"/groups/{self.context.project_id}") + + def _restore_job_payload(self, cluster_id) -> Dict: + return { + "delivery": { + "methodName": "AUTOMATED_RESTORE", + "targetGroupId": self.context.project_id, + "targetClusterId": cluster_id, + }, + } + + def query_backup(self, db_name: str, collection_name: str, timeout: int): + """Query the first backup snapshot and return all records from specified collection.""" + qb = OMQueryableBackup(self.context.base_url, self.context.project_id) + connParams = qb.connection_params(timeout) + + caPem = tempfile.NamedTemporaryFile(delete=False, mode="w") + caPem.write(connParams.ca_pem) + caPem.flush() + + clientPem = tempfile.NamedTemporaryFile(delete=False, mode="w") + clientPem.write(connParams.client_pem) + clientPem.flush() + + dbClient = pymongo.MongoClient( + host=connParams.host, + tls=True, + tlsCAFile=caPem.name, + tlsCertificateKeyFile=clientPem.name, + )[db_name] + collection = dbClient[collection_name] + return list(collection.find()) + + +class OMBackgroundTester(BackgroundHealthChecker): + """ + + Note, that it may return sporadic 500 when the appdb is being restarted, we + won't fail because of this so checking only for + 'allowed_sequential_failures' failures. In practice having + 'allowed_sequential_failures' should work as failures are very rare (1-2 per + appdb upgrade) but let's be safe to avoid e2e flakiness. + + """ + + def __init__( + self, + om_tester: OMTester, + wait_sec: int = 3, + allowed_sequential_failures: int = 3, + ): + super().__init__( + health_function=om_tester.assert_healthiness, + wait_sec=wait_sec, + allowed_sequential_failures=allowed_sequential_failures, + ) + + +# TODO can we move below methods to some other place? + + +def get_agent_cert_names(namespace: str) -> List[str]: + agent_names = ["mms-automation-agent", "mms-backup-agent", "mms-monitoring-agent"] + return ["{}.{}".format(agent_name, namespace) for agent_name in agent_names] + + +def get_rs_cert_names( + mdb_resource: str, + namespace: str, + *, + members: int = 3, + with_internal_auth_certs: bool = False, + with_agent_certs: bool = False, +) -> List[str]: + cert_names = [f"{mdb_resource}-{i}.{namespace}" for i in range(members)] + + if with_internal_auth_certs: + cert_names += [f"{mdb_resource}-{i}-clusterfile.{namespace}" for i in range(members)] + + if with_agent_certs: + cert_names += get_agent_cert_names(namespace) + + return cert_names + + +def get_st_cert_names( + mdb_resource: str, + namespace: str, + *, + with_internal_auth_certs: bool = False, + with_agent_certs: bool = False, +) -> List[str]: + return get_rs_cert_names( + mdb_resource, + namespace, + members=1, + with_internal_auth_certs=with_internal_auth_certs, + with_agent_certs=with_agent_certs, + ) + + +def get_sc_cert_names( + mdb_resource: str, + namespace: str, + *, + num_shards: int = 1, + members: int = 3, + config_members: int = 3, + num_mongos: int = 2, + with_internal_auth_certs: bool = False, + with_agent_certs: bool = False, +) -> List[str]: + names = [] + + for shard_num in range(num_shards): + for member in range(members): + # e.g. test-tls-x509-sc-0-1.developer14 + names.append("{}-{}-{}.{}".format(mdb_resource, shard_num, member, namespace)) + if with_internal_auth_certs: + # e.g. test-tls-x509-sc-0-2-clusterfile.developer14 + names.append("{}-{}-{}-clusterfile.{}".format(mdb_resource, shard_num, member, namespace)) + + for member in range(config_members): + # e.g. test-tls-x509-sc-config-1.developer14 + names.append("{}-config-{}.{}".format(mdb_resource, member, namespace)) + if with_internal_auth_certs: + # e.g. test-tls-x509-sc-config-1-clusterfile.developer14 + names.append("{}-config-{}-clusterfile.{}".format(mdb_resource, member, namespace)) + + for mongos in range(num_mongos): + # e.g.test-tls-x509-sc-mongos-1.developer14 + names.append("{}-mongos-{}.{}".format(mdb_resource, mongos, namespace)) + if with_internal_auth_certs: + # e.g. test-tls-x509-sc-mongos-0-clusterfile.developer14 + names.append("{}-mongos-{}-clusterfile.{}".format(mdb_resource, mongos, namespace)) + + if with_agent_certs: + names.extend(get_agent_cert_names(namespace)) + + return names + + +def should_include_tag(version: Optional[Dict[str, str]]) -> bool: + """Checks if the Ops Manager version API includes the EXTERNALLY_MANAGED tag. + This is, the version of Ops Manager is greater or equals than 4.2.2 or Cloud + Manager. + + """ + feature_controls_enabled_version = "4.2.2" + if version is None: + return True + + if "versionString" not in version: + return True + + if re.match("^v\d+", version["versionString"]): + # Cloud Manager supports Feature Controls + return False + + match = re.match(r"^(\d{1,2}\.\d{1,2}\.\d{1,2}).*", version["versionString"]) + if match: + version_string = match.group(1) + + # version_string is lower than 4.2.2 + return semver.compare(version_string, feature_controls_enabled_version) < 0 + + return True diff --git a/docker/mongodb-enterprise-tests/kubetester/operator.py b/docker/mongodb-enterprise-tests/kubetester/operator.py new file mode 100644 index 000000000..e6ddf25c4 --- /dev/null +++ b/docker/mongodb-enterprise-tests/kubetester/operator.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +from typing import Dict, List, Optional + +import time +import logging + +from kubernetes import client +from kubernetes.client import V1Pod, V1beta1CustomResourceDefinition, V1Deployment +from kubernetes.client.rest import ApiException +from kubetester.create_or_replace_from_yaml import create_or_replace_from_yaml +from kubetester.helm import ( + helm_install, + helm_upgrade, + helm_template, + helm_uninstall, + helm_repo_add, +) + +import requests + +OPERATOR_CRDS = ( + "mongodb.mongodb.com", + "mongodbusers.mongodb.com", + "opsmanagers.mongodb.com", +) + + +class Operator(object): + """Operator is an abstraction over some Operator and relevant resources. It + allows to create and delete the Operator deployment and K8s resources. + + * `helm_args` corresponds to the --set values passed to helm installation. + * `helm_options` refers to the options passed to the helm command. + + The operator is installed from published Helm Charts. + """ + + def __init__( + self, + namespace: str, + helm_args: Optional[Dict] = None, + helm_options: Optional[List[str]] = None, + helm_chart_path: Optional[str] = "helm_chart", + name: Optional[str] = "mongodb-enterprise-operator", + api_client: Optional[client.api_client.ApiClient] = None, + ): + + # The Operator will be installed from the following repo, so adding it first + helm_repo_add("mongodb", "https://mongodb.github.io/helm-charts") + + if helm_args is None: + helm_args = {} + + helm_args["namespace"] = namespace + helm_args["operator.env"] = "dev" + + # the import is done here to prevent circular dependency + from tests.conftest import local_operator + + if local_operator(): + helm_args["operator.replicas"] = "0" + + self.namespace = namespace + self.helm_arguments = helm_args + self.helm_options = helm_options + self.helm_chart_path = helm_chart_path + self.name = name + self.api_client = api_client + + def install_from_template(self): + """Uses helm to generate yaml specification and then uses python K8s client to apply them to the cluster + This is equal to helm template...| kubectl apply -""" + yaml_file = helm_template( + self.helm_arguments, helm_chart_path=self.helm_chart_path + ) + create_or_replace_from_yaml(client.api_client.ApiClient(), yaml_file) + self._wait_for_operator_ready() + self._wait_operator_webhook_is_ready() + + return self + + def install(self) -> Operator: + """Installs the Operator to Kubernetes cluster using 'helm install', waits until it's running""" + helm_install( + "mongodb-enterprise-operator", + self.namespace, + self.helm_arguments, + helm_chart_path=self.helm_chart_path, + helm_options=self.helm_options, + ) + self._wait_for_operator_ready() + self._wait_operator_webhook_is_ready() + + return self + + def upgrade(self, multi_cluster: bool = False) -> Operator: + """Upgrades the Operator in Kubernetes cluster using 'helm upgrade', waits until it's running""" + helm_upgrade( + self.name, + self.namespace, + self.helm_arguments, + helm_chart_path=self.helm_chart_path, + helm_options=self.helm_options, + ) + self._wait_for_operator_ready() + self._wait_operator_webhook_is_ready(multi_cluster=multi_cluster) + + return self + + def uninstall(self): + helm_uninstall(self.name) + + def delete_operator_deployment(self): + """Deletes the Operator deployment from K8s cluster.""" + client.AppsV1Api(api_client=self.api_client).delete_namespaced_deployment( + self.name, self.namespace + ) + + def list_operator_pods(self) -> List[V1Pod]: + pods = ( + client.CoreV1Api(api_client=self.api_client) + .list_namespaced_pod( + self.namespace, + label_selector="app.kubernetes.io/name={}".format(self.name), + ) + .items + ) + return pods + + def read_deployment(self) -> V1Deployment: + return client.AppsV1Api(api_client=self.api_client).read_namespaced_deployment( + self.name, self.namespace + ) + + def assert_is_running(self): + """Makes 3 checks that the Operator is running with 1 second interval. One check is not enough as the Operator may get + to Running state for short and fail later""" + + # the import is done here to prevent circular dependency + from tests.conftest import local_operator + + if local_operator(): + return + + for _ in range(0, 3): + pods = self.list_operator_pods() + assert len(pods) == 1 + assert pods[0].status.phase == "Running" + assert pods[0].status.container_statuses[0].ready + time.sleep(1) + + def _wait_for_operator_ready(self, retries: int = 60): + """waits until the Operator deployment is ready.""" + + # we don't want to wait for the operator if the operator is running locally and not in a pod + from tests.conftest import local_operator + + if local_operator(): + return + + # we need to give some time for the new pod to start instead of the existing one (if any) + time.sleep(4) + retry_count = retries + while retry_count > 0: + pods = self.list_operator_pods() + if len(pods) == 1: + if ( + pods[0].status.phase == "Running" + and pods[0].status.container_statuses[0].ready + ): + return + if pods[0].status.phase == "Failed": + raise Exception( + "Operator failed to start: {}".format(pods[0].status.phase) + ) + time.sleep(1) + retry_count = retry_count - 1 + + # Operator hasn't started - printing some debug information + self.print_diagnostics() + + raise Exception( + f"Operator hasn't started in specified time after {retries} retries." + ) + + def _wait_operator_webhook_is_ready( + self, retries: int = 10, multi_cluster: bool = False + ): + + # we don't want to wait for the operator webhook if the operator is running locally and not in a pod + from tests.conftest import local_operator + + if local_operator(): + return + + # in multi-cluster mode the operator and the test pod are in different clusters(test pod won't be able to talk to webhook), + # so we skip this extra check for multi-cluster + if multi_cluster: + return + + logging.debug("_wait_operator_webhook_is_ready") + validation_endpoint = "validate-mongodb-com-v1-mongodb" + webhook_endpoint = "https://operator-webhook.{}.svc.cluster.local/{}".format( + self.namespace, validation_endpoint + ) + headers = {"Content-Type": "application/json"} + + retry_count = retries + 1 + while retry_count > 0: + retry_count -= 1 + + logging.debug("Waiting for operator/webhook to be functional") + try: + response = requests.post( + webhook_endpoint, headers=headers, verify=False, timeout=2 + ) + except Exception as e: + logging.debug(e) + time.sleep(2) + continue + + try: + # Let's assume that if we get a json response, then the webhook + # is already in place. + response.json() + except Exception: + logging.debug("Didn't get a json response from webhook") + else: + return + + time.sleep(2) + + raise Exception( + "Operator webhook didn't start after {} retries".format(retries) + ) + + def print_diagnostics(self): + logging.info("Operator Deployment: ") + logging.info(self.read_deployment()) + + pods = self.list_operator_pods() + if len(pods) > 0: + logging.info("Operator pods: %d", len(pods)) + logging.info("Operator spec: %s", pods[0].spec) + logging.info("Operator status: %s", pods[0].status) + + def wait_for_webhook(self): + time.sleep(20) + webhook_api = client.AdmissionregistrationV1Api() + client.CoreV1Api().read_namespaced_service("operator-webhook", self.namespace) + + # make sure the validating_webhook is installed. + webhook_api.read_validating_webhook_configuration("mdbpolicy.mongodb.com") + + def disable_webhook(self): + webhook_api = client.AdmissionregistrationV1Api() + + # break the existing webhook + webhook = webhook_api.read_validating_webhook_configuration( + "mdbpolicy.mongodb.com" + ) + + # First webhook is for mongodb validations, second is for ops manager + webhook.webhooks[1].client_config.service.name = "a-non-existent-service" + webhook.metadata.uid = "" + webhook_api.replace_validating_webhook_configuration( + "mdbpolicy.mongodb.com", webhook + ) + + def restart_operator_deployment(self): + client.AppsV1Api(api_client=self.api_client).patch_namespaced_deployment_scale( + self.name, + self.namespace, + [{"op": "replace", "path": "/spec/replicas", "value": 0}], + ) + + # wait till there are 0 operator pods + count = 0 + while count < 6: + pods = self.list_operator_pods() + if len(pods) == 0: + break + time.sleep(3) + + # scale the resource back to 1 + client.AppsV1Api(api_client=self.api_client).patch_namespaced_deployment_scale( + self.name, + self.namespace, + [{"op": "replace", "path": "/spec/replicas", "value": 1}], + ) + + return self._wait_for_operator_ready() + + +def delete_operator_crds(): + for crd_name in OPERATOR_CRDS: + try: + client.ApiextensionsV1Api().delete_custom_resource_definition(crd_name) + except ApiException as e: + if e.status != 404: + raise e + + +def list_operator_crds() -> List[V1beta1CustomResourceDefinition]: + return sorted( + [ + crd + for crd in client.ApiextensionsV1Api() + .list_custom_resource_definition() + .items + if crd.metadata.name in OPERATOR_CRDS + ], + key=lambda crd: crd.metadata.name, + ) diff --git a/docker/mongodb-enterprise-tests/kubetester/opsmanager.py b/docker/mongodb-enterprise-tests/kubetester/opsmanager.py new file mode 100644 index 000000000..8e627dee0 --- /dev/null +++ b/docker/mongodb-enterprise-tests/kubetester/opsmanager.py @@ -0,0 +1,615 @@ +from __future__ import annotations + +import json +import re +from base64 import b64decode +from typing import List, Optional, Dict, Callable + +import kubernetes.client +import requests +from kubeobject import CustomObject +from kubernetes import client +from kubernetes.client.rest import ApiException +from requests.auth import HTTPDigestAuth + +from kubetester import read_secret, create_configmap, create_or_update_secret, create_or_update_configmap +from kubetester.automation_config_tester import AutomationConfigTester +from kubetester.kubetester import ( + KubernetesTester, + build_list_of_hosts, + build_om_org_endpoint, +) +from kubetester.mongodb import MongoDBCommon, Phase, in_desired_state, MongoDB, get_pods +from kubetester.mongotester import ReplicaSetTester +from kubetester.omtester import OMTester, OMContext + + +class MongoDBOpsManager(CustomObject, MongoDBCommon): + def __init__(self, *args, **kwargs): + with_defaults = { + "plural": "opsmanagers", + "kind": "MongoDBOpsManager", + "group": "mongodb.com", + "version": "v1", + } + with_defaults.update(kwargs) + super(MongoDBOpsManager, self).__init__(*args, **with_defaults) + + def appdb_status(self) -> MongoDBOpsManager.AppDbStatus: + return self.AppDbStatus(self) + + def om_status(self) -> MongoDBOpsManager.OmStatus: + return self.OmStatus(self) + + def backup_status(self) -> MongoDBOpsManager.BackupStatus: + return self.BackupStatus(self) + + def assert_reaches(self, fn: Callable[[MongoDBOpsManager], bool], timeout=None): + return self.wait_for(fn, timeout=timeout, should_raise=True) + + def get_appdb_hosts(self): + tester = self.get_om_tester(self.app_db_name()) + tester.assert_group_exists() + return tester.api_get_hosts()["results"] + + def assert_appdb_monitoring_group_was_created(self): + tester = self.get_om_tester(self.app_db_name()) + tester.assert_group_exists() + hosts = tester.api_get_hosts()["results"] + + appdb_resource = self.get_appdb_resource() + resource_name = appdb_resource["metadata"]["name"] + service_name = f"{resource_name}-svc" + namespace = appdb_resource["metadata"]["namespace"] + + appdb_hostnames = [] + for index in range(appdb_resource["spec"]["members"]): + appdb_hostnames.append(f"{resource_name}-{index}.{service_name}.{namespace}.svc.cluster.local") + + def agents_have_registered() -> bool: + monitoring_agents = tester.api_read_monitoring_agents() + expected_number_of_agents_in_standby = ( + len([agent for agent in monitoring_agents if agent["stateName"] == "STANDBY"]) + == self.get_appdb_members_count() - 1 + ) + expected_number_of_agents_are_active = ( + len([agent for agent in monitoring_agents if agent["stateName"] == "ACTIVE"]) == 1 + ) + return expected_number_of_agents_in_standby and expected_number_of_agents_are_active + + KubernetesTester.wait_until(agents_have_registered, timeout=20, sleep_time=5) + + registered_automation_agents = tester.api_read_automation_agents() + assert len(registered_automation_agents) == 0 + + registered_agents = tester.api_read_monitoring_agents() + hostnames = [host["hostname"] for host in hosts] + for hn in appdb_hostnames: + assert hn in hostnames + + for ra in registered_agents: + assert ra["hostname"] in appdb_hostnames + + def get_appdb_resource(self) -> MongoDB: + mdb = MongoDB(name=self.app_db_name(), namespace=self.namespace) + # We "artificially" add SCRAM authentication to make syntax match the normal MongoDB - + # this will let the mongo_uri() method work correctly + # (opsmanager_types.go does the same) + mdb["spec"] = self["spec"]["applicationDatabase"] + mdb["spec"]["type"] = MongoDB.Types.REPLICA_SET + mdb["spec"]["security"] = {"authentication": {"modes": ["SCRAM"]}} + return mdb + + def services(self) -> List[Optional[client.V1Service]]: + """Returns a two element list with internal and external Services. + + Any of them might be None if the Service is not found. + """ + services = [] + service_names = (self.svc_name(), self.external_svc_name()) + + for name in service_names: + try: + svc = client.CoreV1Api().read_namespaced_service(name, self.namespace) + services.append(svc) + except ApiException: + services.append(None) + + return [services[0], services[1]] + + def read_statefulset(self) -> client.V1StatefulSet: + return client.AppsV1Api().read_namespaced_stateful_set(self.name, self.namespace) + + def read_appdb_statefulset(self) -> client.V1StatefulSet: + return client.AppsV1Api().read_namespaced_stateful_set(self.app_db_name(), self.namespace) + + def read_backup_statefulset( + self, + api_client: Optional[client.ApiClient] = None, + ) -> client.V1StatefulSet: + return client.AppsV1Api(api_client=api_client).read_namespaced_stateful_set( + self.backup_daemon_name(), self.namespace + ) + + def read_om_pods(self) -> List[client.V1Pod]: + return [ + client.CoreV1Api().read_namespaced_pod(podname, self.namespace) + for podname in get_pods(self.name + "-{}", self.get_replicas()) + ] + + def read_appdb_pods(self) -> List[client.V1Pod]: + return [ + client.CoreV1Api().read_namespaced_pod(podname, self.namespace) + for podname in get_pods(self.app_db_name() + "-{}", self.get_appdb_members_count()) + ] + + def read_backup_pods(self) -> List[client.V1Pod]: + return [ + client.CoreV1Api().read_namespaced_pod(podname, self.namespace) + for podname in get_pods(self.backup_daemon_name() + "-{}", self.get_backup_members_count()) + ] + + def wait_until_backup_pods_become_ready(self, timeout=300): + def backup_daemons_are_ready(): + try: + backup_pods = self.read_backup_pods() + for backup_pod in backup_pods: + if not backup_pod.status.container_statuses[0].ready: + return False + return True + except Exception as e: + print("Error checking if pod is ready: " + str(e)) + return False + + KubernetesTester.wait_until(backup_daemons_are_ready, timeout=timeout) + + def read_gen_key_secret(self) -> client.V1Secret: + return client.CoreV1Api().read_namespaced_secret(self.name + "-gen-key", self.namespace) + + def read_api_key_secret(self, namespace=None) -> client.V1Secret: + """Reads the API key secret for the global admin created by the Operator. Note, that the secret is + located in the Operator namespace - not Ops Manager one, so the 'namespace' parameter must be passed + if the Ops Manager is installed in a separate namespace""" + if namespace is None: + namespace = self.namespace + return client.CoreV1Api().read_namespaced_secret(self.api_key_secret(namespace), namespace) + + def read_appdb_generated_password_secret(self) -> client.V1Secret: + return client.CoreV1Api().read_namespaced_secret(self.app_db_name() + "-om-password", self.namespace) + + def read_appdb_generated_password(self) -> str: + data = self.read_appdb_generated_password_secret().data + return KubernetesTester.decode_secret(data)["password"] + + def read_appdb_agent_password_secret(self) -> client.V1Secret: + return client.CoreV1Api().read_namespaced_secret(self.app_db_name() + "-agent-password", self.namespace) + + def read_appdb_agent_keyfile_secret(self) -> client.V1Secret: + return client.CoreV1Api().read_namespaced_secret(self.app_db_name() + "-keyfile", self.namespace) + + def read_appdb_connection_url(self) -> str: + secret = client.CoreV1Api().read_namespaced_secret(self.get_appdb_connection_url_secret_name(), self.namespace) + return KubernetesTester.decode_secret(secret.data)["connectionString"] + + def read_appdb_members_from_connection_url_secret(self) -> str: + return re.findall(r"[@,]([^@,\/]+)", self.read_appdb_connection_url()) + + def create_admin_secret( + self, + user_name="jane.doe@example.com", + password="Passw0rd.", + first_name="Jane", + last_name="Doe", + api_client: Optional[client.ApiClient] = None, + ): + data = { + "Username": user_name, + "Password": password, + "FirstName": first_name, + "LastName": last_name, + } + create_or_update_secret(self.namespace, self.get_admin_secret_name(), data, api_client=api_client) + + def get_automation_config_tester(self, **kwargs) -> AutomationConfigTester: + secret = client.CoreV1Api().read_namespaced_secret(self.app_db_name() + "-config", self.namespace).data + automation_config_str = b64decode(secret["cluster-config.json"]).decode("utf-8") + config_json = json.loads(automation_config_str) + return AutomationConfigTester(config_json, **kwargs) + + def get_or_create_mongodb_connection_config_map( + self, + mongodb_name: str, + project_name: str, + namespace=None, + api_client: Optional[client.ApiClient] = None, + ) -> str: + """Creates the configmap containing the information needed to connect to OM""" + config_map_name = f"{mongodb_name}-config" + data = { + "baseUrl": self.om_status().get_url(), + "projectName": project_name, + "orgId": "", + } + + # the namespace can be different from OM one if the MongoDB is created in a separate namespace + if namespace is None: + namespace = self.namespace + + try: + create_configmap(namespace, config_map_name, data, api_client=api_client) + except ApiException as e: + if e.status != 409: + raise + + # If the ConfigMap already exist, it will be updated with + # an updated status_url() + KubernetesTester.update_configmap(namespace, config_map_name, data) + + return config_map_name + + def get_om_tester( + self, + project_name: Optional[str] = None, + base_url: Optional[str] = None, + api_client: Optional[kubernetes.client.ApiClient] = None, + ) -> OMTester: + """Returns the instance of OMTester helping to check the state of Ops Manager deployed in Kubernetes.""" + api_key_secret = read_secret( + KubernetesTester.get_namespace(), + self.api_key_secret(KubernetesTester.get_namespace(), api_client=api_client), + api_client=api_client, + ) + + # Check if it's an old stile secret or a new one + if "publicApiKey" in api_key_secret: + om_context = OMContext( + self.om_status().get_url() if not base_url else base_url, + api_key_secret["user"], + api_key_secret["publicApiKey"], + project_name=project_name, + ) + else: + om_context = OMContext( + self.om_status().get_url() if not base_url else base_url, + api_key_secret["publicKey"], + api_key_secret["privateKey"], + project_name=project_name, + ) + return OMTester(om_context) + + def get_appdb_tester(self, **kwargs) -> ReplicaSetTester: + return ReplicaSetTester( + self.app_db_name(), + replicas_count=self.appdb_status().get_members(), + **kwargs, + ) + + def pod_urls(self): + """Returns http urls to each pod in the Ops Manager""" + return [ + "http://{}".format(host) + for host in build_list_of_hosts(self.name, self.namespace, self.get_replicas(), port=8080) + ] + + def set_version(self, version: Optional[str]): + """Sets a specific `version` if set. If `version` is None, then skip.""" + if version is not None: + self["spec"]["version"] = version + return self + + def update_key_to_programmatic(self): + """ + Attempts to create a Programmatic API Key to be used after updating to + newer OM5, which don't support old-style API Key. + """ + + url = self.om_status().get_url() + whitelist_endpoint = f"{url}/api/public/v1.0/admin/whitelist" + headers = {"Content-Type": "application/json", "Accept": "application/json"} + whitelist_entries = [ + {"cidrBlock": "0.0.0.0/1", "description": "first block"}, + {"cidrBlock": "128.0.0.0/1", "description": "second block"}, + ] + + secret_name = self.api_key_secret(self.namespace) + current_creds = read_secret(self.namespace, secret_name) + user = current_creds["user"] + password = current_creds["publicApiKey"] + auth = HTTPDigestAuth(user, password) + + for entry in whitelist_entries: + response = requests.post(whitelist_endpoint, json=entry, headers=headers, auth=auth) + assert response.status_code == 200 + + data = { + "desc": "Creating a programmatic API key before updating to 5.0.0", + "roles": ["GLOBAL_OWNER"], + } + + endpoint = f"{url}/api/public/v1.0/admin/apiKeys" + response = requests.post(endpoint, json=data, headers=headers, auth=auth) + response_data = response.json() + if "privateKey" not in response_data: + assert response_data == {} + + new_creds = { + "publicApiKey": response_data["privateKey"], + "user": response_data["publicKey"], + } + + KubernetesTester.update_secret(self.namespace, secret_name, new_creds) + + def prepare_upgrade_to_om5(self, version: str): + if version.startswith("5.0"): + self.update_key_to_programmatic() + + def allow_mdb_rc_versions(self): + """ + Sets configurations parameters for OM to be able to download RC versions. + """ + + if "configuration" not in self["spec"]: + self["spec"]["configuration"] = {} + + self["spec"]["configuration"]["mms.featureFlag.automation.mongoDevelopmentVersions"] = "enabled" + self["spec"]["configuration"]["mongodb.release.autoDownload.rc"] = "true" + self["spec"]["configuration"]["mongodb.release.autoDownload.development"] = "true" + + def set_appdb_version(self, version: str): + self["spec"]["applicationDatabase"]["version"] = version + + def __repr__(self): + # FIX: this should be __unicode__ + return "MongoDBOpsManager| status:".format(self.get_status()) + + def get_appdb_members_count(self) -> int: + return self["spec"]["applicationDatabase"]["members"] + + def get_appdb_connection_url_secret_name(self): + return f"{self.app_db_name()}-connection-string" + + def get_replicas(self) -> int: + return self["spec"]["replicas"] + + def get_backup_members_count(self) -> int: + if "backup" not in self["spec"] or "members" not in self["spec"]["backup"]: + return 1 + return self["spec"]["backup"]["members"] + + def get_admin_secret_name(self) -> str: + return self["spec"]["adminCredentials"] + + def get_version(self) -> str: + return self["spec"]["version"] + + def get_status(self) -> Optional[str]: + if "status" not in self: + return None + return self["status"] + + def api_key_secret(self, namespace: str, api_client: Optional[client.ApiClient] = None) -> str: + old_secret_name = self.name + "-admin-key" + + # try to read the old secret, if it's present return it, else return the new secret name + try: + client.CoreV1Api(api_client=api_client).read_namespaced_secret(old_secret_name, namespace) + except ApiException as e: + if e.status == 404: + return "{}-{}-admin-key".format(self.namespace, self.name) + + return old_secret_name + + def app_db_name(self) -> str: + return self.name + "-db" + + def app_db_password_secret_name(self) -> str: + return self.app_db_name() + "-om-user-password" + + def backup_daemon_name(self) -> str: + return self.name + "-backup-daemon" + + def backup_daemon_svc_name(self) -> str: + return self.backup_daemon_name() + "-svc" + + def backup_daemon_pods_names(self) -> List[str]: + return [self.backup_daemon_name() + "-" + str(item) for item in range(self.get_backup_members_count())] + + def backup_daemon_pods_fqdns(self) -> List[str]: + return [ + f"{item}.{self.backup_daemon_svc_name()}.{self.namespace}.svc.cluster.local" + for item in self.backup_daemon_pods_names() + ] + + def svc_name(self) -> str: + return self.name + "-svc" + + def external_svc_name(self) -> str: + return self.name + "-svc-ext" + + def download_mongodb_binaries(self, version: str): + """Downloads mongodb binary in each OM pod, optional downloads MongoDB Tools""" + distros = [ + f"mongodb-linux-x86_64-rhel80-{version}.tgz", + f"mongodb-linux-x86_64-ubuntu1604-{version}.tgz", + f"mongodb-linux-x86_64-ubuntu1804-{version}.tgz", + ] + + for pod in self.read_om_pods(): + for distro in distros: + cmd = [ + "curl", + "-L", + f"https://fastdl.mongodb.org/linux/{distro}", + "-o", + f"/mongodb-ops-manager/mongodb-releases/{distro}", + ] + + KubernetesTester.run_command_in_pod_container(pod.metadata.name, self.namespace, cmd) + + class StatusCommon: + def assert_reaches_phase( + self, + phase: Phase, + msg_regexp=None, + timeout=None, + ignore_errors=False, + ): + self.ops_manager.wait_for( + lambda s: in_desired_state( + current_state=self.get_phase(), + desired_state=phase, + current_generation=self.ops_manager.get_generation(), + observed_generation=self.get_observed_generation(), + current_message=self.get_message(), + msg_regexp=msg_regexp, + ignore_errors=ignore_errors, + ), + timeout, + should_raise=True, + ) + + def assert_abandons_phase(self, phase: Phase, timeout=None): + return self.ops_manager.wait_for(lambda s: self.get_phase() != phase, timeout, should_raise=True) + + def assert_status_resource_not_ready(self, name: str, kind: str = "StatefulSet", msg_regexp=None, idx=0): + """Checks the element in 'resources_not_ready' field by index 'idx'""" + assert self.get_resources_not_ready()[idx]["kind"] == kind + assert self.get_resources_not_ready()[idx]["name"] == name + assert re.search(msg_regexp, self.get_resources_not_ready()[idx]["message"]) is not None + + def assert_empty_status_resources_not_ready(self): + assert self.get_resources_not_ready() is None + + class BackupStatus(StatusCommon): + def __init__(self, ops_manager: MongoDBOpsManager): + self.ops_manager = ops_manager + + def get_phase(self) -> Optional[Phase]: + try: + return Phase[self.ops_manager.get_status()["backup"]["phase"]] + except (KeyError, TypeError): + return None + + def get_message(self) -> Optional[str]: + try: + return self.ops_manager.get_status()["backup"]["message"] + except (KeyError, TypeError): + return None + + def get_observed_generation(self) -> Optional[int]: + try: + return self.ops_manager.get_status()["backup"]["observedGeneration"] + except (KeyError, TypeError): + return None + + def get_resources_not_ready(self) -> Optional[List[Dict]]: + try: + return self.ops_manager.get_status()["backup"]["resourcesNotReady"] + except (KeyError, TypeError): + return None + + def assert_reaches_phase( + self, + phase: Phase, + msg_regexp=None, + timeout=None, + ignore_errors=False, + ): + super().assert_reaches_phase( + phase, + msg_regexp=msg_regexp, + timeout=timeout, + ignore_errors=ignore_errors, + ) + # If backup is Running other statuses must be Running as well + # So far let's comment this as sometimes there are some extra reconciliations happening + # (doing no work at all) without known reasons for + # if phase == Phase.Running: + # assert self.ops_manager.om_status().get_phase() == Phase.Running + # assert self.ops_manager.appdb_status().get_phase() == Phase.Running + + class AppDbStatus(StatusCommon): + def __init__(self, ops_manager: MongoDBOpsManager): + self.ops_manager = ops_manager + + def get_phase(self) -> Optional[Phase]: + try: + return Phase[self.ops_manager.get_status()["applicationDatabase"]["phase"]] + except (KeyError, TypeError): + return None + + def get_message(self) -> Optional[str]: + try: + return self.ops_manager.get_status()["applicationDatabase"]["message"] + except (KeyError, TypeError): + return None + + def get_observed_generation(self) -> Optional[int]: + try: + return self.ops_manager.get_status()["applicationDatabase"]["observedGeneration"] + except (KeyError, TypeError): + return None + + def get_version(self) -> Optional[str]: + try: + return self.ops_manager.get_status()["applicationDatabase"]["version"] + except (KeyError, TypeError): + return None + + def get_members(self) -> Optional[int]: + try: + return self.ops_manager.get_status()["applicationDatabase"]["members"] + except (KeyError, TypeError): + return None + + def get_resources_not_ready(self) -> Optional[List[Dict]]: + try: + return self.ops_manager.get_status()["applicationDatabase"]["resourcesNotReady"] + except (KeyError, TypeError): + return None + + class OmStatus(StatusCommon): + def __init__(self, ops_manager: MongoDBOpsManager): + self.ops_manager = ops_manager + + def get_phase(self) -> Optional[Phase]: + try: + return Phase[self.ops_manager.get_status()["opsManager"]["phase"]] + except (KeyError, TypeError): + return None + + def get_message(self) -> Optional[str]: + try: + return self.ops_manager.get_status()["opsManager"]["message"] + except (KeyError, TypeError): + return None + + def get_observed_generation(self) -> Optional[int]: + try: + return self.ops_manager.get_status()["opsManager"]["observedGeneration"] + except (KeyError, TypeError): + return None + + def get_last_transition(self) -> Optional[int]: + try: + return self.ops_manager.get_status()["opsManager"]["lastTransition"] + except (KeyError, TypeError): + return None + + def get_url(self) -> Optional[str]: + try: + return self.ops_manager.get_status()["opsManager"]["url"] + except (KeyError, TypeError): + return None + + def get_replicas(self) -> Optional[int]: + try: + return self.ops_manager.get_status()["opsManager"]["replicas"] + except (KeyError, TypeError): + return None + + def get_resources_not_ready(self) -> Optional[List[Dict]]: + try: + return self.ops_manager.get_status()["opsManager"]["resourcesNotReady"] + except (KeyError, TypeError): + return None diff --git a/docker/mongodb-enterprise-tests/kubetester/security_context.py b/docker/mongodb-enterprise-tests/kubetester/security_context.py new file mode 100644 index 000000000..b44930f11 --- /dev/null +++ b/docker/mongodb-enterprise-tests/kubetester/security_context.py @@ -0,0 +1,31 @@ +from kubernetes import client + + +def assert_pod_security_context(pod: client.V1Pod, managed: bool): + sc = pod.spec.security_context + + if managed: + # Note, that this code is a bit fragile as may depend on the version of Openshift. + assert sc.fs_group != 2000 + # se_linux_options is set on Openshift + assert sc.se_linux_options.level is not None + assert sc.se_linux_options.role is None + assert sc.se_linux_options.type is None + assert sc.run_as_non_root + assert sc.run_as_user == 2000 + else: + assert sc.fs_group == 2000 + # In Kops and Kind se_linux_options is set to None + assert sc.se_linux_options is None + + assert sc.run_as_group is None + + +def assert_pod_container_security_context(container: client.V1Container, managed: bool): + sc = container.security_context + + if managed: + assert sc is None + else: + assert sc.read_only_root_filesystem + assert sc.allow_privilege_escalation is False diff --git a/docker/mongodb-enterprise-tests/kubetester/test_identifiers.py b/docker/mongodb-enterprise-tests/kubetester/test_identifiers.py new file mode 100644 index 000000000..2ab240efa --- /dev/null +++ b/docker/mongodb-enterprise-tests/kubetester/test_identifiers.py @@ -0,0 +1,46 @@ +import os + +import yaml +from typing import Any + +from kubetester.kubetester import running_locally + +test_identifiers: dict = None + + +def set_test_identifier(identifier: str, value: Any) -> Any: + """ + Persists random test identifier for subsequent local runs. + + Useful for creating resources with random names, e.g. S3 buckets. + When the identifier exists it disregards passed value and returns saved one. + Appends identifier to .test_identifier file in working directory. + """ + global test_identifiers + + if not running_locally(): + return value + + # this check is for in-memory cache, if the value already exists we're trying to set value again for the same key + if test_identifiers is not None and identifier in test_identifiers: + raise Exception( + f"cannot override {identifier} test identifier, existing value: {test_identifiers[identifier]}, new value: {value}" + ) + + test_identifiers_file = ".test_identifiers" + if test_identifiers is None: + if os.path.exists(test_identifiers_file): + with open("%s" % test_identifiers_file) as f: + test_identifiers = yaml.safe_load(f) + else: + test_identifiers = dict() + + if identifier in test_identifiers: + return test_identifiers[identifier] + + test_identifiers[identifier] = value + + with open("%s" % test_identifiers_file, "w") as f: + yaml.dump(test_identifiers, f) + + return test_identifiers[identifier] diff --git a/docker/mongodb-enterprise-tests/kubetester/vault.py b/docker/mongodb-enterprise-tests/kubetester/vault.py new file mode 100644 index 000000000..e69de29bb diff --git a/docker/mongodb-enterprise-tests/pyproject.toml b/docker/mongodb-enterprise-tests/pyproject.toml new file mode 100644 index 000000000..80809ebaa --- /dev/null +++ b/docker/mongodb-enterprise-tests/pyproject.toml @@ -0,0 +1,4 @@ +[tool.black] +line-length = 120 +target-version = ['py39'] +include = '\.pyi?$' diff --git a/docker/mongodb-enterprise-tests/pytest.ini b/docker/mongodb-enterprise-tests/pytest.ini new file mode 100644 index 000000000..21e21532f --- /dev/null +++ b/docker/mongodb-enterprise-tests/pytest.ini @@ -0,0 +1,32 @@ +[pytest] + +# Run tests under the tests directory +testpaths = tests + +# Only files matching the following globs will be read as part of the test suite. +# The files under `mixed` match the *_test.py because they are generic in nature. +python_files = *.py + +# -s -- disable stdout capture: should be added to `PYTEST_ADDOPTS` if needed +# -x -- stop after first failure +# -v -- increase verbosity +# -rf -- show a failure summary at the end, more here: https://docs.pytest.org/en/7.1.x/how-to/output.html#producing-a-detailed-summary-report +addopts = -x -rf -v --color=yes --setup-show -s --junitxml=/results/myreport.xml + +# Logging configuration +# log_cli = 1 # Set this to have logs printed right away instead of captured. +log_cli_level = INFO +log_cli_format = %(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s) +log_cli_date_format=%Y-%m-%d %H:%M:%S + +# by default, marking a test with xfail will not cause a test suite failure. +# setting it to true ensures a failed test suite on an unexpected pass. +xfail_strict = true + +# With newer 6.x version of pytest, the markers need to be defined in this file +# in a 'markers' list. +# To avoid this warning we can add the following filter, or add each custom mark. +# TODO: Start using filenames to invoke the tests, and not the markers; we have +# abused the concept too much and pytest 6.0 is complaining about it. +filterwarnings = + ignore::UserWarning diff --git a/docker/mongodb-enterprise-tests/requirements-dev.txt b/docker/mongodb-enterprise-tests/requirements-dev.txt new file mode 100644 index 000000000..6c2b4e2a3 --- /dev/null +++ b/docker/mongodb-enterprise-tests/requirements-dev.txt @@ -0,0 +1,7 @@ +-r requirements.txt +jedi +rope +black==22.12.0 +flake8 +autopep8 +jinja2 diff --git a/docker/mongodb-enterprise-tests/requirements.txt b/docker/mongodb-enterprise-tests/requirements.txt new file mode 100644 index 000000000..28520454e --- /dev/null +++ b/docker/mongodb-enterprise-tests/requirements.txt @@ -0,0 +1,19 @@ +kubeobject==0.2.1 +chardet==3.0.4 +jsonpatch==1.32 +kubernetes==17.17.0 +pymongo==3.11.0 +pytest==6.0.2 +pytest-asyncio==0.14.0 +PyYAML==5.4.1 +requests==2.31.0 +urllib3==1.26.6 +dnspython==2.0.0 +cryptography==39.0.1 +boto3==1.18.8 +python-dateutil==2.8.1 +semver==2.10.2 +python-ldap==3.4.0 +GitPython==3.1.30 +setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability + diff --git a/docker/mongodb-enterprise-tests/results/myreport.xml b/docker/mongodb-enterprise-tests/results/myreport.xml new file mode 100644 index 000000000..68eee3978 --- /dev/null +++ b/docker/mongodb-enterprise-tests/results/myreport.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docker/mongodb-enterprise-tests/tests/__init__.py b/docker/mongodb-enterprise-tests/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/docker/mongodb-enterprise-tests/tests/authentication/__init__.py b/docker/mongodb-enterprise-tests/tests/authentication/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/docker/mongodb-enterprise-tests/tests/authentication/conftest.py b/docker/mongodb-enterprise-tests/tests/authentication/conftest.py new file mode 100644 index 000000000..cd933e54e --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/conftest.py @@ -0,0 +1,258 @@ +import os +from typing import List, Generator, Optional, Dict + +from kubernetes import client +from kubetester import get_pod_when_ready, read_secret +from kubetester.certs import generate_cert +from kubetester.helm import helm_install, helm_uninstall, helm_upgrade +from kubetester.kubetester import KubernetesTester +from kubetester.mongodb_multi import MultiClusterClient +from kubetester.ldap import ( + create_user, + ensure_organizational_unit, + ensure_group, + ensure_organization, + add_user_to_group, + OpenLDAP, + LDAPUser, + ldap_initialize, +) +from pytest import fixture + +LDAP_PASSWORD = "LDAPPassword." +LDAP_NAME = "openldap" +LDAP_POD_LABEL = "app=openldap" +LDAP_PORT_PLAIN = 389 +LDAP_PORT_TLS = 636 +LDAP_PROTO_PLAIN = "ldap" +LDAP_PROTO_TLS = "ldaps" + +AUTOMATION_AGENT_NAME = "mms-automation-agent" + + +def pytest_runtest_setup(item): + """This allows to automatically install the default Operator before running any test""" + if "default_operator" not in item.fixturenames: + item.fixturenames.insert(0, "default_operator") + + +def openldap_install( + namespace: str, + name: str = LDAP_NAME, + cluster_client: Optional[client.ApiClient] = None, + cluster_name: Optional[str] = None, + helm_args: Dict[str, str] = None, + tls: bool = False, +) -> OpenLDAP: + if cluster_name is not None: + os.environ["HELM_KUBECONTEXT"] = cluster_name + + if helm_args is None: + helm_args = {} + helm_args.update( + {"namespace": namespace, "fullnameOverride": name, "nameOverride": name} + ) + + # check if the openldap pod exists, if not do a helm upgrade + pods = client.CoreV1Api(api_client=cluster_client).list_namespaced_pod( + namespace, label_selector=f"app={name}" + ) + if not pods.items: + print(f"performing helm upgrade of openldap") + + helm_upgrade( + name=name, + namespace=namespace, + helm_args=helm_args, + helm_chart_path="vendor/openldap", + helm_override_path=True, + ) + get_pod_when_ready(namespace, f"app={name}", api_client=cluster_client) + + if tls: + return OpenLDAP( + ldap_url(namespace, name, LDAP_PROTO_TLS, LDAP_PORT_TLS), + ldap_admin_password(namespace, name, api_client=cluster_client), + ) + + return OpenLDAP( + ldap_url(namespace, name), + ldap_admin_password(namespace, name, api_client=cluster_client), + ) + + +@fixture(scope="module") +def openldap_tls( + namespace: str, + openldap_cert: str, + ca_path: str, +) -> Generator[OpenLDAP, None, None]: + """Installs an OpenLDAP server with TLS configured and returns a reference to it. + + In order to do it, this fixture will install the vendored openldap Helm chart + located in `vendor/openldap` directory inside the `tests` container image. + """ + + helm_args = { + "tls.enabled": "true", + "tls.secret": openldap_cert, + # Do not require client certificates + "env.LDAP_TLS_VERIFY_CLIENT": "never", + "namespace": namespace, + } + server = openldap_install(namespace, name=LDAP_NAME, helm_args=helm_args, tls=True) + # When creating a new OpenLDAP container with TLS enabled, the container is ready, but the server is not accepting + # requests, as it's generating DH parameters for the TLS config. Only using retries!=0 for ldap_initialize when creating + # the OpenLDAP server. + ldap_initialize(server, ca_path=ca_path, retries=10) + return server + + +@fixture(scope="module") +def openldap(namespace: str) -> Generator[OpenLDAP, None, None]: + """Installs a OpenLDAP server and returns a reference to it. + + In order to do it, this fixture will install the vendored openldap Helm chart + located in `vendor/openldap` directory inside the `tests` container image. + """ + yield openldap_install(namespace, LDAP_NAME) + + helm_uninstall(LDAP_NAME) + + +@fixture(scope="module") +def secondary_openldap(namespace: str) -> Generator[OpenLDAP, None, None]: + yield openldap_install(namespace, f"{LDAP_NAME}secondary") + + helm_uninstall(f"{LDAP_NAME}secondary") + + +@fixture(scope="module") +def openldap_cert(namespace: str, issuer: str) -> str: + """Returns a new secret to be used to enable TLS on LDAP.""" + host = ldap_host(namespace, LDAP_NAME) + return generate_cert(namespace, "openldap", host, issuer) + + +@fixture(scope="module") +def ldap_mongodb_user_tls(openldap_tls: OpenLDAP, ca_path: str) -> LDAPUser: + user = LDAPUser("mdb0", LDAP_PASSWORD) + create_user(openldap_tls, user, ca_path=ca_path) + + return user + + +@fixture(scope="module") +def ldap_mongodb_x509_agent_user( + openldap: OpenLDAP, namespace: str, ca_path: str +) -> LDAPUser: + organization_name = "cluster.local-agent" + user = LDAPUser( + AUTOMATION_AGENT_NAME, + LDAP_PASSWORD, + ) + + ensure_organization(openldap, organization_name, ca_path=ca_path) + + ensure_organizational_unit( + openldap, namespace, o=organization_name, ca_path=ca_path + ) + create_user(openldap, user, ou=namespace, o=organization_name) + + ensure_group( + openldap, + cn=AUTOMATION_AGENT_NAME, + ou=namespace, + o=organization_name, + ca_path=ca_path, + ) + + add_user_to_group( + openldap, + user=AUTOMATION_AGENT_NAME, + group_cn=AUTOMATION_AGENT_NAME, + ou=namespace, + o=organization_name, + ) + return user + + +@fixture(scope="module") +def ldap_mongodb_agent_user(openldap: OpenLDAP) -> LDAPUser: + user = LDAPUser(AUTOMATION_AGENT_NAME, LDAP_PASSWORD) + + ensure_organizational_unit(openldap, "groups") + create_user(openldap, user, ou="groups") + + ensure_group(openldap, cn="agents", ou="groups") + add_user_to_group( + openldap, user=AUTOMATION_AGENT_NAME, group_cn="agents", ou="groups" + ) + + return user + + +@fixture(scope="module") +def secondary_ldap_mongodb_agent_user(secondary_openldap: OpenLDAP) -> LDAPUser: + user = LDAPUser(AUTOMATION_AGENT_NAME, LDAP_PASSWORD) + + ensure_organizational_unit(secondary_openldap, "groups") + create_user(secondary_openldap, user, ou="groups") + + ensure_group(secondary_openldap, cn="agents", ou="groups") + add_user_to_group( + secondary_openldap, user=AUTOMATION_AGENT_NAME, group_cn="agents", ou="groups" + ) + + return user + + +@fixture(scope="module") +def ldap_mongodb_user(openldap: OpenLDAP) -> LDAPUser: + user = LDAPUser("mdb0", LDAP_PASSWORD) + + ensure_organizational_unit(openldap, "groups") + create_user(openldap, user, ou="groups") + + ensure_group(openldap, cn="users", ou="groups") + add_user_to_group(openldap, user="mdb0", group_cn="users", ou="groups") + + return user + + +@fixture(scope="module") +def ldap_mongodb_users(openldap: OpenLDAP) -> List[LDAPUser]: + user_list = [LDAPUser("mdb0", LDAP_PASSWORD)] + for user in user_list: + create_user(openldap, user) + + return user_list + + +@fixture(scope="module") +def secondary_ldap_mongodb_users(secondary_openldap: OpenLDAP) -> List[LDAPUser]: + user_list = [LDAPUser("mdb0", LDAP_PASSWORD)] + for user in user_list: + create_user(secondary_openldap, user) + + return user_list + + +def ldap_host(namespace: str, name: str) -> str: + return "{}.{}.svc.cluster.local".format(name, namespace) + + +def ldap_url( + namespace: str, + name: str, + proto: str = LDAP_PROTO_PLAIN, + port: int = LDAP_PORT_PLAIN, +) -> str: + host = ldap_host(namespace, name) + return "{}://{}:{}".format(proto, host, port) + + +def ldap_admin_password( + namespace: str, name: str, api_client: Optional[client.ApiClient] = None +) -> str: + return read_secret(namespace, name, api_client=api_client)["LDAP_ADMIN_PASSWORD"] diff --git a/docker/mongodb-enterprise-tests/tests/authentication/fixtures/ldap/ldap-agent-auth.yaml b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/ldap/ldap-agent-auth.yaml new file mode 100644 index 000000000..de23b5f2e --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/ldap/ldap-agent-auth.yaml @@ -0,0 +1,32 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: ldap-replica-set +spec: + type: ReplicaSet + members: 3 + version: 4.4.4-ent + + # make persistent by default to speed up the test + + + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + security: + authentication: + enabled: true + + agents: + mode: "" + + modes: ["LDAP", "SCRAM"] + ldap: + bindQueryUser: "" + servers: "" + bindQueryPasswordSecretRef: + name: "" + transportSecurity: "" diff --git a/docker/mongodb-enterprise-tests/tests/authentication/fixtures/ldap/ldap-replica-set-roles.yaml b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/ldap/ldap-replica-set-roles.yaml new file mode 100644 index 000000000..242b831cc --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/ldap/ldap-replica-set-roles.yaml @@ -0,0 +1,43 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: ldap-replica-set +spec: + type: ReplicaSet + members: 3 + version: 4.4.0-ent + + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + security: + authentication: + agents: + mode: "SCRAM" + enabled: true + modes: ["LDAP", "SCRAM"] + ldap: + bindQueryUser: "" + servers: "" + bindQueryPasswordSecretRef: + name: "" + transportSecurity: "" + roles: + - role: "cn=users,ou=groups,dc=example,dc=org" + db: admin + privileges: + - actions: + - insert + resource: + collection: foo + db: foo + - actions: + - insert + - find + resource: + collection: "" + db: admin + diff --git a/docker/mongodb-enterprise-tests/tests/authentication/fixtures/ldap/ldap-replica-set.yaml b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/ldap/ldap-replica-set.yaml new file mode 100644 index 000000000..a79974304 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/ldap/ldap-replica-set.yaml @@ -0,0 +1,27 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: ldap-replica-set +spec: + type: ReplicaSet + members: 3 + version: 4.4.0-ent + + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + security: + authentication: + agents: + mode: "SCRAM" + enabled: true + modes: ["LDAP", "SCRAM"] + ldap: + bindQueryUser: "" + servers: "" + bindQueryPasswordSecretRef: + name: "" + transportSecurity: "" diff --git a/docker/mongodb-enterprise-tests/tests/authentication/fixtures/ldap/ldap-sharded-cluster-user.yaml b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/ldap/ldap-sharded-cluster-user.yaml new file mode 100644 index 000000000..23cd96f21 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/ldap/ldap-sharded-cluster-user.yaml @@ -0,0 +1,17 @@ +--- +- apiVersion: mongodb.com/v1 + kind: MongoDBUser + metadata: + name: ldap-user-0 + spec: + username: "uid=mdb0,dc=example,dc=org" + db: "$external" + mongodbResourceRef: + name: ldap-sharded-cluster + roles: + - db: "admin" + name: "clusterAdmin" + - db: "admin" + name: "readWriteAnyDatabase" + - db: "admin" + name: "dbAdminAnyDatabase" diff --git a/docker/mongodb-enterprise-tests/tests/authentication/fixtures/ldap/ldap-sharded-cluster.yaml b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/ldap/ldap-sharded-cluster.yaml new file mode 100644 index 000000000..fb292389b --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/ldap/ldap-sharded-cluster.yaml @@ -0,0 +1,38 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: ldap-sharded-cluster +spec: + type: ShardedCluster + + shardCount: 1 + mongodsPerShardCount: 3 + mongosCount: 1 + configServerCount: 3 + + version: 4.4.0-ent + + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + security: + authentication: + enabled: true + + # Enabled LDAP Authentication Mode + modes: ["LDAP"] + + ldap: + servers: "" + transportSecurity: "tls" + + # Specify the LDAP Distinguished Name to which + # MongoDB binds when connecting to the LDAP server + bindQueryUser: "cn=admin,dc=example,dc=org" + + bindQueryPasswordSecretRef: + name: "" + diff --git a/docker/mongodb-enterprise-tests/tests/authentication/fixtures/ldap/ldap-user.yaml b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/ldap/ldap-user.yaml new file mode 100644 index 000000000..1a885070f --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/ldap/ldap-user.yaml @@ -0,0 +1,17 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDBUser +metadata: + name: ldap-user-0 +spec: + username: "uid=mdb0,dc=example,dc=org" + db: "$external" + mongodbResourceRef: + name: ldap-replica-set + roles: + - db: "admin" + name: "clusterAdmin" + - db: "admin" + name: "readWriteAnyDatabase" + - db: "admin" + name: "dbAdminAnyDatabase" diff --git a/docker/mongodb-enterprise-tests/tests/authentication/fixtures/replica-set-basic.yaml b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/replica-set-basic.yaml new file mode 100644 index 000000000..41b8f97b2 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/replica-set-basic.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: replica-set +spec: + members: 3 + version: 4.2.2 + type: ReplicaSet + credentials: my-credentials + opsManager: + configMapRef: + name: my-project + diff --git a/docker/mongodb-enterprise-tests/tests/authentication/fixtures/replica-set-explicit-scram-sha-1.yaml b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/replica-set-explicit-scram-sha-1.yaml new file mode 100644 index 000000000..6f675c15b --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/replica-set-explicit-scram-sha-1.yaml @@ -0,0 +1,23 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: my-replica-set-scram-sha-1 +spec: + members: 3 + version: 5.0.5 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + persistent: false + security: + authentication: + agents: + # This may look weird, but without it we'll get this from OpsManager: + # Cannot configure SCRAM-SHA-1 without using MONGODB-CR in te Agent Mode","reason":"Cannot configure SCRAM-SHA-1 without using MONGODB-CR in te Agent Mode + mode: MONGODB-CR + enabled: true + modes: ["SCRAM-SHA-1", "MONGODB-CR"] diff --git a/docker/mongodb-enterprise-tests/tests/authentication/fixtures/replica-set-scram-sha-256-x509-internal-cluster.yaml b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/replica-set-scram-sha-256-x509-internal-cluster.yaml new file mode 100644 index 000000000..035eb9818 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/replica-set-scram-sha-256-x509-internal-cluster.yaml @@ -0,0 +1,24 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: my-replica-set +spec: + members: 3 + version: 4.4.0 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + persistent: false + security: + tls: + enabled: true + authentication: + agents: + mode: SCRAM + enabled: true + modes: ["SCRAM", "X509"] + internalCluster: X509 diff --git a/docker/mongodb-enterprise-tests/tests/authentication/fixtures/replica-set-scram-sha-256.yaml b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/replica-set-scram-sha-256.yaml new file mode 100644 index 000000000..e659aa4d4 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/replica-set-scram-sha-256.yaml @@ -0,0 +1,19 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: my-replica-set +spec: + members: 3 + version: 4.4.0 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + persistent: false + security: + authentication: + enabled: true + modes: ["SCRAM"] diff --git a/docker/mongodb-enterprise-tests/tests/authentication/fixtures/replica-set-scram.yaml b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/replica-set-scram.yaml new file mode 100644 index 000000000..780f7aba5 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/replica-set-scram.yaml @@ -0,0 +1,21 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: my-replica-set-scram +spec: + members: 3 + version: 4.4.2 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + persistent: false + security: + authentication: + agents: + mode: SCRAM + enabled: true + modes: ["SCRAM"] diff --git a/docker/mongodb-enterprise-tests/tests/authentication/fixtures/replica-set-tls-scram-sha-256.yaml b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/replica-set-tls-scram-sha-256.yaml new file mode 100644 index 000000000..17bba38ca --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/replica-set-tls-scram-sha-256.yaml @@ -0,0 +1,21 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: replica-set-scram-256-and-x509 +spec: + members: 3 + version: 4.4.0 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + persistent: false + security: + tls: + enabled: true + authentication: + enabled: true + modes: ["SCRAM"] diff --git a/docker/mongodb-enterprise-tests/tests/authentication/fixtures/replica-set-x509-to-scram-256.yaml b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/replica-set-x509-to-scram-256.yaml new file mode 100644 index 000000000..d6390ea17 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/replica-set-x509-to-scram-256.yaml @@ -0,0 +1,23 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: replica-set-x509-to-scram-256 +spec: + members: 3 + version: 4.4.0-ent + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + persistent: false + security: + tls: + enabled: true + authentication: + agents: + mode: X509 + enabled: true + modes: ["X509"] diff --git a/docker/mongodb-enterprise-tests/tests/authentication/fixtures/scram-sha-user.yaml b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/scram-sha-user.yaml new file mode 100644 index 000000000..f1a6b07b9 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/scram-sha-user.yaml @@ -0,0 +1,22 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDBUser +metadata: + name: mms-user-1 +spec: + passwordSecretKeyRef: + name: mms-user-1-password + key: password + username: "mms-user-1" + db: "admin" + mongodbResourceRef: + name: "my-replica-set" + roles: + - db: "admin" + name: "clusterAdmin" + - db: "admin" + name: "userAdminAnyDatabase" + - db: "admin" + name: "readWrite" + - db: "admin" + name: "userAdminAnyDatabase" diff --git a/docker/mongodb-enterprise-tests/tests/authentication/fixtures/sharded-cluster-explicit-scram-sha-1.yaml b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/sharded-cluster-explicit-scram-sha-1.yaml new file mode 100644 index 000000000..fdd0b4a6a --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/sharded-cluster-explicit-scram-sha-1.yaml @@ -0,0 +1,26 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: my-sharded-cluster-scram-sha-1 +spec: + shardCount: 1 + type: ShardedCluster + mongodsPerShardCount: 3 + mongosCount: 2 + configServerCount: 3 + version: 5.0.5 + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + persistent: false + security: + authentication: + agents: + # This may look weird, but without it we'll get this from OpsManager: + # Cannot configure SCRAM-SHA-1 without using MONGODB-CR in te Agent Mode","reason":"Cannot configure SCRAM-SHA-1 without using MONGODB-CR in te Agent Mode + mode: MONGODB-CR + enabled: true + modes: ["SCRAM-SHA-1", "MONGODB-CR"] diff --git a/docker/mongodb-enterprise-tests/tests/authentication/fixtures/sharded-cluster-scram-sha-1.yaml b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/sharded-cluster-scram-sha-1.yaml new file mode 100644 index 000000000..192167e52 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/sharded-cluster-scram-sha-1.yaml @@ -0,0 +1,24 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: my-sharded-cluster-scram-sha-1 +spec: + shardCount: 1 + type: ShardedCluster + mongodsPerShardCount: 3 + mongosCount: 2 + configServerCount: 3 + version: 4.4.2 + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: WARN + persistent: false + security: + authentication: + agents: + mode: SCRAM + enabled: true + modes: ["SCRAM"] diff --git a/docker/mongodb-enterprise-tests/tests/authentication/fixtures/sharded-cluster-scram-sha-256-x509-internal-cluster.yaml b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/sharded-cluster-scram-sha-256-x509-internal-cluster.yaml new file mode 100644 index 000000000..ec49c5713 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/sharded-cluster-scram-sha-256-x509-internal-cluster.yaml @@ -0,0 +1,28 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: sharded-cluster-scram-sha-256 +spec: + shardCount: 1 + mongodsPerShardCount: 3 + mongosCount: 2 + configServerCount: 3 + version: 4.4.0 + type: ShardedCluster + + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + persistent: false + security: + tls: + enabled: true + authentication: + agents: + mode: SCRAM + enabled: true + modes: [ "SCRAM", "X509" ] + internalCluster: X509 diff --git a/docker/mongodb-enterprise-tests/tests/authentication/fixtures/sharded-cluster-scram-sha-256.yaml b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/sharded-cluster-scram-sha-256.yaml new file mode 100644 index 000000000..2508d6b0c --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/sharded-cluster-scram-sha-256.yaml @@ -0,0 +1,23 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: sharded-cluster-scram-sha-256 +spec: + shardCount: 1 + mongodsPerShardCount: 3 + mongosCount: 2 + configServerCount: 3 + version: 4.4.0 + type: ShardedCluster + + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + persistent: false + security: + authentication: + enabled: true + modes: ["SCRAM"] diff --git a/docker/mongodb-enterprise-tests/tests/authentication/fixtures/sharded-cluster-tls-scram-sha-256.yaml b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/sharded-cluster-tls-scram-sha-256.yaml new file mode 100644 index 000000000..24c2bcba1 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/sharded-cluster-tls-scram-sha-256.yaml @@ -0,0 +1,25 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: sharded-cluster-tls-scram-sha-256 +spec: + shardCount: 1 + mongodsPerShardCount: 3 + mongosCount: 2 + configServerCount: 3 + version: 4.4.0 + type: ShardedCluster + + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + persistent: false + security: + tls: + enabled: true + authentication: + enabled: true + modes: ["SCRAM"] diff --git a/docker/mongodb-enterprise-tests/tests/authentication/fixtures/sharded-cluster-x509-internal-cluster-auth-transition.yaml b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/sharded-cluster-x509-internal-cluster-auth-transition.yaml new file mode 100644 index 000000000..d051ba898 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/sharded-cluster-x509-internal-cluster-auth-transition.yaml @@ -0,0 +1,27 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: sc-internal-cluster-auth-transition +spec: + shardCount: 2 + mongodsPerShardCount: 3 + mongosCount: 1 + configServerCount: 1 + version: 4.4.0-ent + type: ShardedCluster + + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + persistent: true + security: + tls: + enabled: true + authentication: + agents: + mode: X509 + enabled: true + modes: ["X509"] diff --git a/docker/mongodb-enterprise-tests/tests/authentication/fixtures/sharded-cluster-x509-to-scram-256.yaml b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/sharded-cluster-x509-to-scram-256.yaml new file mode 100644 index 000000000..2921de354 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/fixtures/sharded-cluster-x509-to-scram-256.yaml @@ -0,0 +1,27 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: sharded-cluster-x509-to-scram-256 +spec: + shardCount: 1 + mongodsPerShardCount: 2 + mongosCount: 1 + configServerCount: 2 + version: 4.4.0-ent + type: ShardedCluster + + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + persistent: true + security: + tls: + enabled: true + authentication: + agents: + mode: X509 + enabled: true + modes: ["X509"] diff --git a/docker/mongodb-enterprise-tests/tests/authentication/replica_set_agent_ldap.py b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_agent_ldap.py new file mode 100644 index 000000000..e220bb05a --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_agent_ldap.py @@ -0,0 +1,194 @@ +from pytest import mark, fixture + +from kubetester import create_secret, find_fixture + +from kubetester.mongodb import MongoDB, Phase +from kubetester.mongodb_user import MongoDBUser, generic_user, Role +from kubetester.ldap import OpenLDAP, LDAPUser +from tests.opsmanager.conftest import ensure_ent_version + +USER_NAME = "mms-user-1" +PASSWORD = "my-password" + + +@fixture(scope="module") +def replica_set(openldap: OpenLDAP, namespace: str) -> MongoDB: + resource = MongoDB.from_yaml( + find_fixture("ldap/ldap-agent-auth.yaml"), namespace=namespace + ) + + secret_name = "bind-query-password" + create_secret(namespace, secret_name, {"password": openldap.admin_password}) + + ac_secret_name = "automation-config-password" + create_secret( + namespace, ac_secret_name, {"automationConfigPassword": "LDAPPassword."} + ) + + resource["spec"]["security"]["authentication"]["ldap"] = { + "servers": [openldap.servers], + "bindQueryUser": "cn=admin,dc=example,dc=org", + "bindQueryPasswordSecretRef": {"name": secret_name}, + "userToDNMapping": '[{match: "(.+)",substitution: "uid={0},ou=groups,dc=example,dc=org"}]', + } + resource["spec"]["security"]["authentication"]["agents"] = { + "mode": "LDAP", + "automationPasswordSecretRef": { + "name": ac_secret_name, + "key": "automationConfigPassword", + }, + "automationUserName": "mms-automation-agent", + } + + return resource.create() + + +@fixture(scope="module") +def ldap_user_mongodb( + replica_set: MongoDB, namespace: str, ldap_mongodb_user: LDAPUser +) -> MongoDBUser: + """Returns a list of MongoDBUsers (already created) and their corresponding passwords.""" + user = generic_user( + namespace, + username=ldap_mongodb_user.uid, + db="$external", + mongodb_resource=replica_set, + password=ldap_mongodb_user.password, + ) + user.add_roles( + [ + # In order to be able to write to custom db/collections during the tests + Role(db="admin", role="readWriteAnyDatabase"), + ] + ) + + return user.create() + + +@mark.e2e_replica_set_ldap_agent_auth +@mark.usefixtures("ldap_mongodb_agent_user", "ldap_user_mongodb") +def test_replica_set(replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running, timeout=400) + + +@mark.e2e_replica_set_ldap_agent_auth +def test_new_ldap_users_can_authenticate( + replica_set: MongoDB, ldap_user_mongodb: MongoDBUser +): + tester = replica_set.tester() + + tester.assert_ldap_authentication( + username=ldap_user_mongodb["spec"]["username"], + password=ldap_user_mongodb.password, + db="customDb", + collection="customColl", + attempts=10, + ) + + +@mark.e2e_replica_set_ldap_agent_auth +def test_deployment_is_reachable_with_ldap_agent(replica_set: MongoDB): + tester = replica_set.tester() + # Due to what we found out in + # https://jira.mongodb.org/browse/CLOUDP-68873 + # the agents might report being in goal state, the MDB resource + # would report no errors but the deployment would be unreachable + # See the comment inside the function for further details + tester.assert_deployment_reachable(attempts=10) + + +@mark.e2e_replica_set_ldap_agent_auth +def test_scale_replica_test(replica_set: MongoDB): + replica_set.reload() + replica_set["spec"]["members"] = 5 + replica_set.update() + replica_set.assert_abandons_phase(Phase.Running) + replica_set.assert_reaches_phase(Phase.Running, timeout=600) + + +@mark.e2e_replica_set_ldap_agent_auth +def test_new_ldap_users_can_authenticate_after_scaling( + replica_set: MongoDB, ldap_user_mongodb: MongoDBUser +): + tester = replica_set.tester() + + tester.assert_ldap_authentication( + username=ldap_user_mongodb["spec"]["username"], + password=ldap_user_mongodb.password, + db="customDb", + collection="customColl", + attempts=10, + ) + + +@mark.e2e_replica_set_ldap_agent_auth +def test_disable_agent_auth(replica_set: MongoDB): + replica_set.reload() + replica_set["spec"]["security"]["authentication"]["enabled"] = False + replica_set["spec"]["security"]["authentication"]["agents"]["enabled"] = False + replica_set.update() + replica_set.assert_abandons_phase(Phase.Running) + replica_set.assert_reaches_phase(Phase.Running, timeout=900) + + +@mark.e2e_replica_set_ldap_agent_auth +def test_replica_set_connectivity_with_no_auth(replica_set: MongoDB): + tester = replica_set.tester() + tester.assert_connectivity() + + +@mark.e2e_replica_set_ldap_agent_auth +def test_deployment_is_reachable_with_no_auth(replica_set: MongoDB): + tester = replica_set.tester() + tester.assert_deployment_reachable(attempts=10) + + +@mark.e2e_replica_set_ldap_agent_auth +def test_replica_set_connectivity_after_version_change_no_auth(replica_set: MongoDB): + tester = replica_set.tester() + tester.assert_connectivity() + + +@mark.e2e_replica_set_ldap_agent_auth +def test_deployment_is_reachable_after_version_change(replica_set: MongoDB): + tester = replica_set.tester() + tester.assert_deployment_reachable(attempts=10) + + +@mark.e2e_replica_set_ldap_agent_auth +def test_enable_SCRAM_auth(replica_set: MongoDB): + replica_set.reload() + replica_set["spec"]["security"]["authentication"]["agents"]["enabled"] = True + replica_set["spec"]["security"]["authentication"]["agents"]["mode"] = "SCRAM" + replica_set["spec"]["security"]["authentication"]["enabled"] = True + replica_set["spec"]["security"]["authentication"]["mode"] = "SCRAM" + replica_set.update() + replica_set.assert_abandons_phase(Phase.Running) + replica_set.assert_reaches_phase(Phase.Running, timeout=700) + + +@mark.e2e_replica_set_ldap_agent_auth +def test_replica_set_connectivity_with_SCRAM_auth(replica_set: MongoDB): + tester = replica_set.tester() + tester.assert_connectivity() + + +@mark.e2e_replica_set_ldap_agent_auth +def test_change_version_to_latest(replica_set: MongoDB, custom_mdb_version: str): + replica_set.reload() + replica_set["spec"]["version"] = ensure_ent_version(custom_mdb_version) + replica_set.update() + replica_set.assert_abandons_phase(Phase.Running) + replica_set.assert_reaches_phase(Phase.Running, timeout=900) + + +@mark.e2e_replica_set_ldap_agent_auth +def test_replica_set_connectivity_after_version_change_SCRAM(replica_set: MongoDB): + tester = replica_set.tester() + tester.assert_connectivity() + + +@mark.e2e_replica_set_ldap_agent_auth +def test_deployment_is_reachable_after_version_change_SCRAM(replica_set: MongoDB): + tester = replica_set.tester() + tester.assert_deployment_reachable(attempts=10) diff --git a/docker/mongodb-enterprise-tests/tests/authentication/replica_set_custom_roles.py b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_custom_roles.py new file mode 100644 index 000000000..c0ecde680 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_custom_roles.py @@ -0,0 +1,141 @@ +from pytest import mark, fixture + +from kubetester import create_secret, find_fixture, create_or_update + +from kubetester.mongodb import MongoDB, Phase +from kubetester.mongodb_user import MongoDBUser, generic_user +from kubetester.ldap import OpenLDAP, LDAPUser, LDAP_AUTHENTICATION_MECHANISM + + +@fixture(scope="module") +def replica_set( + openldap: OpenLDAP, + issuer_ca_configmap: str, + namespace: str, + ldap_mongodb_user: LDAPUser, +) -> MongoDB: + resource = MongoDB.from_yaml( + find_fixture("ldap/ldap-replica-set-roles.yaml"), namespace=namespace + ) + + secret_name = "bind-query-password" + create_secret(namespace, secret_name, {"password": openldap.admin_password}) + + resource["spec"]["security"]["authentication"]["ldap"] = { + "servers": [openldap.servers], + "bindQueryUser": "cn=admin,dc=example,dc=org", + "bindQueryPasswordSecretRef": {"name": secret_name}, + "validateLDAPServerConfig": True, + "caConfigMapRef": {"name": issuer_ca_configmap, "key": "ca-pem"}, + "userToDNMapping": '[{match: "(.+)",substitution: "uid={0},ou=groups,dc=example,dc=org"}]', + "authzQueryTemplate": "{USER}?memberOf?base", + } + + create_or_update(resource) + return resource + + +@fixture(scope="module") +def ldap_user_mongodb( + replica_set: MongoDB, + namespace: str, + ldap_mongodb_user: LDAPUser, + openldap: OpenLDAP, +) -> MongoDBUser: + """Returns a list of MongoDBUsers (already created) and their corresponding passwords.""" + user = generic_user( + namespace, + username=ldap_mongodb_user.uid, + db="$external", + mongodb_resource=replica_set, + password=ldap_mongodb_user.password, + ) + + create_or_update(user) + return user + + +@mark.e2e_replica_set_custom_roles +def test_replica_set(replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running, timeout=400) + + +@mark.e2e_replica_set_custom_roles +def test_create_ldap_user(replica_set: MongoDB, ldap_user_mongodb: MongoDBUser): + ldap_user_mongodb.assert_reaches_phase(Phase.Updated) + + ac = replica_set.get_automation_config_tester() + ac.assert_authentication_mechanism_enabled( + LDAP_AUTHENTICATION_MECHANISM, active_auth_mechanism=False + ) + ac.assert_expected_users(1) + + +@mark.e2e_replica_set_custom_roles +def test_new_ldap_users_can_write_to_database( + replica_set: MongoDB, ldap_user_mongodb: MongoDBUser +): + tester = replica_set.tester() + + tester.assert_ldap_authentication( + username=ldap_user_mongodb["spec"]["username"], + password=ldap_user_mongodb.password, + db="foo", + collection="foo", + attempts=10, + ) + + +@mark.e2e_replica_set_custom_roles +@mark.xfail( + reason="The user should not be able to write to a database/collection it is not authorized to write on" +) +def test_new_ldap_users_can_write_to_other_collection( + replica_set: MongoDB, ldap_user_mongodb: MongoDBUser +): + tester = replica_set.tester() + + tester.assert_ldap_authentication( + username=ldap_user_mongodb["spec"]["username"], + password=ldap_user_mongodb.password, + db="foo", + collection="foo2", + attempts=10, + ) + + +@mark.e2e_replica_set_custom_roles +@mark.xfail( + reason="The user should not be able to write to a database/collection it is not authorized to write on" +) +def test_new_ldap_users_can_write_to_other_database( + replica_set: MongoDB, ldap_user_mongodb: MongoDBUser +): + tester = replica_set.tester() + tester.assert_ldap_authentication( + username=ldap_user_mongodb["spec"]["username"], + password=ldap_user_mongodb.password, + db="foo2", + collection="foo", + attempts=10, + ) + + +@mark.e2e_replica_set_custom_roles +def test_automation_config_has_roles(replica_set: MongoDB): + tester = replica_set.get_automation_config_tester() + + tester.assert_has_expected_number_of_roles(expected_roles=1) + role = { + "role": "cn=users,ou=groups,dc=example,dc=org", + "db": "admin", + "privileges": [ + {"actions": ["insert"], "resource": {"collection": "foo", "db": "foo"}}, + { + "actions": ["insert", "find"], + "resource": {"collection": "", "db": "admin"}, + }, + ], + "authenticationRestrictions": [], + } + tester.assert_expected_role(role_index=0, expected_value=role) diff --git a/docker/mongodb-enterprise-tests/tests/authentication/replica_set_feature_controls.py b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_feature_controls.py new file mode 100644 index 000000000..83de257b1 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_feature_controls.py @@ -0,0 +1,121 @@ +from pytest import fixture, mark + +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.mongodb import Phase, MongoDB + + +@fixture(scope="module") +def replicaset(namespace: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-basic.yaml"), + namespace=namespace, + ) + + return resource.create() + + +@mark.e2e_feature_controls_authentication +def test_replicaset_reaches_running_phase(replicaset: MongoDB): + replicaset.assert_reaches_phase(Phase.Running, ignore_errors=True) + + +@mark.e2e_feature_controls_authentication +def test_authentication_is_owned_by_opsmanager(replicaset: MongoDB): + """ + There is no authentication, so feature controls API should allow + authentication changes from Ops Manager UI. + """ + fc = replicaset.get_om_tester().get_feature_controls() + assert fc["externalManagementSystem"]["name"] == "mongodb-enterprise-operator" + + assert len(fc["policies"]) == 2 + policies = [p["policy"] for p in fc["policies"]] + assert "EXTERNALLY_MANAGED_LOCK" in policies + assert "DISABLE_SET_MONGOD_VERSION" in policies + + for p in fc["policies"]: + if p["policy"] == "EXTERNALLY_MANAGED_LOCK": + assert p["disabledParams"] == [] + + +@mark.e2e_feature_controls_authentication +def test_authentication_disabled_is_owned_by_operator(replicaset: MongoDB): + """ + Authentication has been added to the Spec, on "disabled" mode, + this makes the Operator to *own* authentication and thus + making Feature controls API to restrict any + """ + replicaset["spec"]["security"] = {"authentication": {"enabled": False}} + replicaset.update() + + replicaset.assert_abandons_phase(Phase.Running) + replicaset.assert_reaches_phase(Phase.Running) + + fc = replicaset.get_om_tester().get_feature_controls() + assert fc["externalManagementSystem"]["name"] == "mongodb-enterprise-operator" + + policies = sorted(fc["policies"], key=lambda policy: policy["policy"]) + assert len(fc["policies"]) == 3 + + assert policies[0]["disabledParams"] == [] + assert policies[2]["disabledParams"] == [] + + policies = [p["policy"] for p in fc["policies"]] + assert "EXTERNALLY_MANAGED_LOCK" in policies + assert "DISABLE_AUTHENTICATION_MECHANISMS" in policies + assert "DISABLE_SET_MONGOD_VERSION" in policies + + +@mark.e2e_feature_controls_authentication +def test_authentication_enabled_is_owned_by_operator(replicaset: MongoDB): + """ + Authentication has been enabled on the Operator. Authentication is still + owned by the operator so feature controls should be kept the same. + """ + replicaset["spec"]["security"] = { + "authentication": {"enabled": True, "modes": ["SCRAM"]} + } + replicaset.update() + + replicaset.assert_abandons_phase(Phase.Running) + replicaset.assert_reaches_phase(Phase.Running, timeout=500) + + fc = replicaset.get_om_tester().get_feature_controls() + + assert fc["externalManagementSystem"]["name"] == "mongodb-enterprise-operator" + + assert len(fc["policies"]) == 3 + # sort the policies to have pre-determined order + policies = sorted(fc["policies"], key=lambda policy: policy["policy"]) + assert policies[0]["disabledParams"] == [] + assert policies[2]["disabledParams"] == [] + + policies = [p["policy"] for p in fc["policies"]] + assert "EXTERNALLY_MANAGED_LOCK" in policies + assert "DISABLE_AUTHENTICATION_MECHANISMS" in policies + assert "DISABLE_SET_MONGOD_VERSION" in policies + + +@mark.e2e_feature_controls_authentication +def test_authentication_disabled_owned_by_opsmanager(replicaset: MongoDB): + """ + Authentication has been disabled (removed) on the Operator. Authentication + is now "owned" by Ops Manager. + """ + last_transition = replicaset.get_status_last_transition_time() + replicaset["spec"]["security"] = None + replicaset.update() + + replicaset.assert_state_transition_happens(last_transition) + replicaset.assert_reaches_phase(Phase.Running) + + fc = replicaset.get_om_tester().get_feature_controls() + + assert fc["externalManagementSystem"]["name"] == "mongodb-enterprise-operator" + + # sort the policies to have pre-determined order + policies = sorted(fc["policies"], key=lambda policy: policy["policy"]) + assert len(fc["policies"]) == 2 + assert policies[0]["policy"] == "DISABLE_SET_MONGOD_VERSION" + assert policies[1]["policy"] == "EXTERNALLY_MANAGED_LOCK" + assert policies[1]["disabledParams"] == [] diff --git a/docker/mongodb-enterprise-tests/tests/authentication/replica_set_ignore_unkown_users.py b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_ignore_unkown_users.py new file mode 100644 index 000000000..ead8ca32f --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_ignore_unkown_users.py @@ -0,0 +1,59 @@ +from pytest import mark, fixture + +from kubetester import find_fixture + +from kubetester.mongodb import MongoDB, Phase + + +@fixture(scope="module") +def replica_set( + namespace: str, +) -> MongoDB: + resource = MongoDB.from_yaml( + find_fixture("replica-set-scram-sha-256.yaml"), namespace=namespace + ) + + resource["spec"]["security"]["authentication"]["ignoreUnknownUsers"] = True + + return resource.create() + + +@mark.e2e_replica_set_ignore_unknown_users +def test_replica_set(replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running, timeout=400) + + +@mark.e2e_replica_set_ignore_unknown_users +def test_authoritative_set_false(replica_set: MongoDB): + tester = replica_set.get_automation_config_tester() + tester.assert_authoritative_set(False) + + +@mark.e2e_replica_set_ignore_unknown_users +def test_set_ignore_unknown_users_false(replica_set: MongoDB): + replica_set.reload() + replica_set["spec"]["security"]["authentication"]["ignoreUnknownUsers"] = False + replica_set.update() + replica_set.assert_abandons_phase(Phase.Running) + replica_set.assert_reaches_phase(Phase.Running, timeout=400) + + +@mark.e2e_replica_set_ignore_unknown_users +def test_authoritative_set_true(replica_set: MongoDB): + tester = replica_set.get_automation_config_tester() + tester.assert_authoritative_set(True) + + +@mark.e2e_replica_set_ignore_unknown_users +def test_set_ignore_unknown_users_true(replica_set: MongoDB): + replica_set.reload() + replica_set["spec"]["security"]["authentication"]["ignoreUnknownUsers"] = True + replica_set.update() + replica_set.assert_abandons_phase(Phase.Running) + replica_set.assert_reaches_phase(Phase.Running, timeout=400) + + +@mark.e2e_replica_set_ignore_unknown_users +def test_authoritative_set_false_again(replica_set: MongoDB): + tester = replica_set.get_automation_config_tester() + tester.assert_authoritative_set(False) diff --git a/docker/mongodb-enterprise-tests/tests/authentication/replica_set_ldap.py b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_ldap.py new file mode 100644 index 000000000..b6e67c43e --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_ldap.py @@ -0,0 +1,337 @@ +from typing import List + +from pytest import mark, fixture + +from kubetester import find_fixture, create_secret + +from kubetester.certs import create_mongodb_tls_certs +from kubetester.mongodb import MongoDB, Phase +from kubetester.mongodb_user import MongoDBUser, generic_user, Role +from kubetester.ldap import OpenLDAP, LDAPUser, LDAP_AUTHENTICATION_MECHANISM +from kubetester.certs import create_x509_user_cert +import tempfile + +from kubetester.kubetester import KubernetesTester + +USER_NAME = "mms-user-1" +PASSWORD = "my-password" +MDB_RESOURCE = "ldap-replica-set" + + +@fixture(scope="module") +def server_certs(namespace: str, issuer: str): + create_mongodb_tls_certs( + issuer, namespace, "ldap-replica-set", "certs-ldap-replica-set-cert" + ) + return "certs" + + +@fixture(scope="module") +def replica_set( + openldap: OpenLDAP, + issuer_ca_configmap: str, + ldap_mongodb_agent_user: LDAPUser, + server_certs: str, + namespace: str, +) -> MongoDB: + resource = MongoDB.from_yaml( + find_fixture("ldap/ldap-replica-set.yaml"), namespace=namespace + ) + + secret_name = "bind-query-password" + create_secret(namespace, secret_name, {"password": openldap.admin_password}) + ac_secret_name = "automation-config-password" + create_secret( + namespace, + ac_secret_name, + {"automationConfigPassword": ldap_mongodb_agent_user.password}, + ) + + resource["spec"]["security"] = { + "tls": { + "enabled": True, + "ca": issuer_ca_configmap, + }, + "certsSecretPrefix": server_certs, + "authentication": { + "enabled": True, + "modes": ["LDAP", "SCRAM", "X509"], + "ldap": { + "servers": [openldap.servers], + "bindQueryUser": "cn=admin,dc=example,dc=org", + "bindQueryPasswordSecretRef": {"name": secret_name}, + }, + "agents": { + "mode": "LDAP", + "automationPasswordSecretRef": { + "name": ac_secret_name, + "key": "automationConfigPassword", + }, + "automationUserName": ldap_mongodb_agent_user.uid, + }, + }, + } + + return resource.create() + + +@fixture(scope="module") +def user_ldap( + replica_set: MongoDB, namespace: str, ldap_mongodb_users: List[LDAPUser] +) -> MongoDBUser: + mongodb_user = ldap_mongodb_users[0] + user = generic_user( + namespace, + username=mongodb_user.username, + db="$external", + password=mongodb_user.password, + mongodb_resource=replica_set, + ) + user.add_roles( + [ + Role(db="admin", role="clusterAdmin"), + Role(db="admin", role="readWriteAnyDatabase"), + Role(db="admin", role="dbAdminAnyDatabase"), + ] + ) + + return user.create() + + +@fixture(scope="module") +def user_scram(replica_set: MongoDB, namespace: str) -> MongoDBUser: + user = generic_user( + namespace, + username="mms-user-1", + db="admin", + mongodb_resource=replica_set, + ) + secret_name = "user-password" + secret_key = "password" + create_secret(namespace, secret_name, {secret_key: "my-password"}) + user["spec"]["passwordSecretKeyRef"] = { + "name": secret_name, + "key": secret_key, + } + + user.add_roles( + [ + Role(db="admin", role="clusterAdmin"), + Role(db="admin", role="readWriteAnyDatabase"), + Role(db="admin", role="dbAdminAnyDatabase"), + ] + ) + + return user.create() + + +@mark.e2e_replica_set_ldap +def test_replica_set(replica_set: MongoDB, ldap_mongodb_users: List[LDAPUser]): + replica_set.assert_reaches_phase(Phase.Running, timeout=400) + + +@mark.e2e_replica_set_ldap +def test_create_ldap_user(replica_set: MongoDB, user_ldap: MongoDBUser): + user_ldap.assert_reaches_phase(Phase.Updated) + + ac = replica_set.get_automation_config_tester() + ac.assert_authentication_mechanism_enabled( + LDAP_AUTHENTICATION_MECHANISM, active_auth_mechanism=True + ) + ac.assert_expected_users(1) + + +@mark.e2e_replica_set_ldap +def test_new_mdb_users_are_created_and_can_authenticate( + replica_set: MongoDB, user_ldap: MongoDBUser, ca_path: str +): + tester = replica_set.tester() + + tester.assert_ldap_authentication( + username=user_ldap["spec"]["username"], + password=user_ldap.password, + ssl_ca_certs=ca_path, + attempts=10, + ) + + +@mark.e2e_replica_set_ldap +def test_create_scram_user(replica_set: MongoDB, user_scram: MongoDBUser): + user_scram.assert_reaches_phase(Phase.Updated) + + ac = replica_set.get_automation_config_tester() + ac.assert_authentication_mechanism_enabled( + "SCRAM-SHA-256", active_auth_mechanism=False + ) + ac.assert_expected_users(2) + + +@mark.e2e_replica_set_ldap +def test_replica_set_connectivity(replica_set: MongoDB, ca_path: str): + tester = replica_set.tester(ca_path=ca_path) + tester.assert_connectivity() + + +@mark.e2e_replica_set_ldap +def test_ops_manager_state_correctly_updated( + replica_set: MongoDB, user_ldap: MongoDBUser +): + expected_roles = { + ("admin", "clusterAdmin"), + ("admin", "readWriteAnyDatabase"), + ("admin", "dbAdminAnyDatabase"), + } + + tester = replica_set.get_automation_config_tester() + tester.assert_expected_users(2) + tester.assert_has_user(user_ldap["spec"]["username"]) + tester.assert_user_has_roles(user_ldap["spec"]["username"], expected_roles) + + tester.assert_authentication_mechanism_enabled( + "SCRAM-SHA-256", active_auth_mechanism=False + ) + tester.assert_authentication_enabled(expected_num_deployment_auth_mechanisms=3) + + +@mark.e2e_replica_set_ldap +def test_user_cannot_authenticate_with_incorrect_password( + replica_set: MongoDB, ca_path: str +): + tester = replica_set.tester(ca_path=ca_path) + + tester.assert_scram_sha_authentication_fails( + password="invalid-password", + username="mms-user-1", + auth_mechanism="SCRAM-SHA-256", + ssl=True, + ssl_ca_certs=ca_path, + ) + + +@mark.e2e_replica_set_ldap +def test_user_can_authenticate_with_correct_password( + replica_set: MongoDB, ca_path: str +): + tester = replica_set.tester(ca_path=ca_path) + tester.assert_scram_sha_authentication( + password=PASSWORD, + username="mms-user-1", + auth_mechanism="SCRAM-SHA-256", + ssl=True, + ssl_ca_certs=ca_path, + ) + + +@fixture(scope="module") +def user_x509(replica_set: MongoDB, namespace: str) -> MongoDBUser: + user = generic_user( + namespace, + username="CN=x509-testing-user", + db="$external", + mongodb_resource=replica_set, + ) + + user.add_roles( + [ + Role(db="admin", role="clusterAdmin"), + Role(db="admin", role="readWriteAnyDatabase"), + Role(db="admin", role="dbAdminAnyDatabase"), + ] + ) + + return user.create() + + +@mark.e2e_replica_set_ldap +def test_x509_user_created(replica_set: MongoDB, user_x509: MongoDBUser): + user_x509.assert_reaches_phase(Phase.Updated) + + expected_roles = { + ("admin", "clusterAdmin"), + ("admin", "readWriteAnyDatabase"), + ("admin", "dbAdminAnyDatabase"), + } + + tester = replica_set.get_automation_config_tester() + tester.assert_expected_users(3) + tester.assert_has_user(user_x509["spec"]["username"]) + tester.assert_user_has_roles(user_x509["spec"]["username"], expected_roles) + + tester.assert_authentication_mechanism_enabled( + "MONGODB-X509", active_auth_mechanism=False + ) + + +@mark.e2e_replica_set_ldap +def test_x509_user_connectivity( + namespace: str, + ca_path: str, + issuer: str, + replica_set: MongoDB, + user_x509: MongoDBUser, +): + cert_file = tempfile.NamedTemporaryFile(delete=False, mode="w") + create_x509_user_cert(issuer, namespace, path=cert_file.name) + + tester = replica_set.tester() + tester.assert_x509_authentication( + cert_file_name=cert_file.name, ssl_ca_certs=ca_path + ) + + +@mark.e2e_replica_set_ldap +def test_change_ldap_servers( + namespace: str, + replica_set: MongoDB, + secondary_openldap: OpenLDAP, + secondary_ldap_mongodb_users: List[LDAPUser], + secondary_ldap_mongodb_agent_user, +): + secret_name = "bind-query-password-secondary" + create_secret( + namespace, secret_name, {"password": secondary_openldap.admin_password} + ) + ac_secret_name = "automation-config-password-secondary" + create_secret( + namespace, + ac_secret_name, + {"automationConfigPassword": secondary_ldap_mongodb_agent_user.password}, + ) + replica_set.load() + replica_set["spec"]["security"]["authentication"]["ldap"]["servers"] = [ + secondary_openldap.servers + ] + replica_set["spec"]["security"]["authentication"]["ldap"][ + "bindQueryPasswordSecretRef" + ] = {"name": secret_name} + replica_set["spec"]["security"]["authentication"]["agents"] = { + "mode": "LDAP", + "automationPasswordSecretRef": { + "name": ac_secret_name, + "key": "automationConfigPassword", + }, + "automationUserName": secondary_ldap_mongodb_agent_user.uid, + } + + replica_set.update() + replica_set.assert_reaches_phase(Phase.Running, timeout=600) + + +@mark.e2e_replica_set_ldap +def test_replica_set_ldap_settings_are_updated( + replica_set: MongoDB, ldap_mongodb_users: List[LDAPUser] +): + replica_set.reload() + replica_set["spec"]["security"]["authentication"]["ldap"]["timeoutMS"] = 12345 + replica_set["spec"]["security"]["authentication"]["ldap"][ + "userCacheInvalidationInterval" + ] = 60 + replica_set.update() + + replica_set.assert_reaches_phase(Phase.Running, timeout=400) + + ac = replica_set.get_automation_config_tester() + assert "timeoutMS" in ac.automation_config["ldap"] + assert "userCacheInvalidationInterval" in ac.automation_config["ldap"] + assert ac.automation_config["ldap"]["timeoutMS"] == 12345 + assert ac.automation_config["ldap"]["userCacheInvalidationInterval"] == 60 diff --git a/docker/mongodb-enterprise-tests/tests/authentication/replica_set_ldap_agent_client_certs.py b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_ldap_agent_client_certs.py new file mode 100644 index 000000000..c532bf31b --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_ldap_agent_client_certs.py @@ -0,0 +1,230 @@ +import tempfile + +from pytest import mark, fixture + +from kubetester import create_secret, read_secret, delete_secret, find_fixture + +from kubetester.mongodb import MongoDB, Phase +from kubetester.mongodb_user import MongoDBUser, generic_user, Role +from kubetester.ldap import OpenLDAP, LDAPUser +from kubetester.certs import generate_cert, create_mongodb_tls_certs, ISSUER_CA_NAME + +USER_NAME = "mms-user-1" +PASSWORD = "my-password" + + +@fixture(scope="module") +def server_certs(issuer: str, namespace: str): + resource_name = "ldap-replica-set" + return create_mongodb_tls_certs( + ISSUER_CA_NAME, + namespace, + resource_name, + f"{resource_name}-cert", + replicas=5, + ) + + +@fixture(scope="module") +def client_cert_path(issuer: str, namespace: str): + spec = { + "commonName": "client-cert", + "subject": {"organizationalUnits": ["mongodb.com"]}, + } + + client_secret = generate_cert( + namespace, + "client-cert", + "client-cert", + issuer, + spec=spec, + ) + client_cert = read_secret(namespace, client_secret) + cert_file = tempfile.NamedTemporaryFile() + with open(cert_file.name, "w") as f: + f.write(client_cert["tls.key"] + client_cert["tls.crt"]) + + yield cert_file.name + + cert_file.close() + + +@fixture(scope="module") +def agent_client_cert(issuer: str, namespace: str) -> str: + spec = { + "commonName": "mms-automation-client-cert", + "subject": {"organizationalUnits": ["mongodb.com"]}, + } + + client_certificate_secret = generate_cert( + namespace, + "mongodb-mms-automation", + "mongodb-mms-automation", + issuer, + spec=spec, + ) + automation_agent_cert = read_secret(namespace, client_certificate_secret) + data = {} + data["tls.crt"], data["tls.key"] = ( + automation_agent_cert["tls.crt"], + automation_agent_cert["tls.key"], + ) + # creates a secret that combines key and crt + create_secret(namespace, "agent-client-cert", data, type="kubernetes.io/tls") + + yield "agent-client-cert" + + +@fixture(scope="module") +def replica_set( + openldap: OpenLDAP, + issuer: str, + issuer_ca_configmap: str, + server_certs: str, + agent_client_cert: str, + namespace: str, +) -> MongoDB: + resource = MongoDB.from_yaml( + find_fixture("ldap/ldap-agent-auth.yaml"), namespace=namespace + ) + + secret_name = "bind-query-password" + create_secret(namespace, secret_name, {"password": openldap.admin_password}) + + ac_secret_name = "automation-config-password" + create_secret( + namespace, ac_secret_name, {"automationConfigPassword": "LDAPPassword."} + ) + + resource["spec"]["security"]["tls"] = { + "enabled": True, + "ca": issuer_ca_configmap, + } + + resource["spec"]["security"]["authentication"]["ldap"] = { + "servers": [openldap.servers], + "bindQueryUser": "cn=admin,dc=example,dc=org", + "bindQueryPasswordSecretRef": {"name": secret_name}, + "userToDNMapping": '[{match: "(.+)",substitution: "uid={0},ou=groups,dc=example,dc=org"}]', + } + resource["spec"]["security"]["authentication"]["agents"] = { + "mode": "LDAP", + "automationPasswordSecretRef": { + "name": ac_secret_name, + "key": "automationConfigPassword", + }, + "clientCertificateSecretRef": {"name": agent_client_cert}, + "automationUserName": "mms-automation-agent", + } + + return resource.create() + + +@fixture(scope="module") +def ldap_user_mongodb( + replica_set: MongoDB, namespace: str, ldap_mongodb_user: LDAPUser +) -> MongoDBUser: + """Returns a list of MongoDBUsers (already created) and their corresponding passwords.""" + user = generic_user( + namespace, + username=ldap_mongodb_user.uid, + db="$external", + mongodb_resource=replica_set, + password=ldap_mongodb_user.password, + ) + user.add_roles( + [ + # In order to be able to write to custom db/collections during the tests + Role(db="admin", role="readWriteAnyDatabase"), + ] + ) + + return user.create() + + +@mark.e2e_replica_set_ldap_agent_client_certs +@mark.usefixtures("ldap_mongodb_agent_user", "ldap_user_mongodb") +def test_replica_set(replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running, timeout=400) + + +@mark.e2e_replica_set_ldap_agent_client_certs +def test_new_ldap_users_can_authenticate( + replica_set: MongoDB, ldap_user_mongodb: MongoDBUser, ca_path: str +): + tester = replica_set.tester() + + tester.assert_ldap_authentication( + username=ldap_user_mongodb["spec"]["username"], + password=ldap_user_mongodb.password, + db="customDb", + collection="customColl", + ssl_ca_certs=ca_path, + attempts=10, + ) + + +@mark.e2e_replica_set_ldap_agent_client_certs +def test_client_requires_certs(replica_set: MongoDB): + replica_set.load() + replica_set["spec"]["security"]["authentication"][ + "requireClientTLSAuthentication" + ] = True + replica_set.update() + + replica_set.assert_abandons_phase(Phase.Running, timeout=400) + replica_set.assert_reaches_phase(Phase.Running, timeout=400) + + ac_tester = replica_set.get_automation_config_tester() + ac_tester.assert_tls_client_certificate_mode("REQUIRE") + + +@mark.e2e_replica_set_ldap_agent_client_certs +def test_client_can_auth_with_client_certs_provided( + replica_set: MongoDB, + ldap_user_mongodb: MongoDBUser, + ca_path: str, + client_cert_path: str, +): + tester = replica_set.tester() + + tester.assert_ldap_authentication( + username=ldap_user_mongodb["spec"]["username"], + password=ldap_user_mongodb.password, + db="customDb", + collection="customColl", + ssl_ca_certs=ca_path, + ssl_certfile=client_cert_path, + attempts=10, + ) + + +@mark.e2e_replica_set_ldap_agent_client_certs +def test_client_certs_made_optional(replica_set: MongoDB): + replica_set.load() + replica_set["spec"]["security"]["authentication"][ + "requireClientTLSAuthentication" + ] = False + replica_set.update() + + replica_set.assert_abandons_phase(Phase.Running, timeout=400) + replica_set.assert_reaches_phase(Phase.Running, timeout=400) + + ac_tester = replica_set.get_automation_config_tester() + ac_tester.assert_tls_client_certificate_mode("OPTIONAL") + + +@mark.e2e_replica_set_ldap_agent_client_certs +def test_client_can_auth_again_with_no_client_certs( + replica_set: MongoDB, ldap_user_mongodb: MongoDBUser, ca_path: str +): + tester = replica_set.tester() + + tester.assert_ldap_authentication( + username=ldap_user_mongodb["spec"]["username"], + password=ldap_user_mongodb.password, + db="customDb", + collection="customColl", + ssl_ca_certs=ca_path, + attempts=10, + ) diff --git a/docker/mongodb-enterprise-tests/tests/authentication/replica_set_ldap_group_dn.py b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_ldap_group_dn.py new file mode 100644 index 000000000..1e17818e1 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_ldap_group_dn.py @@ -0,0 +1,109 @@ +from pytest import mark, fixture + +from kubetester import create_secret, find_fixture + +from kubetester.mongodb import MongoDB, Phase +from kubetester.mongodb_user import MongoDBUser, generic_user +from kubetester.ldap import OpenLDAP, LDAPUser + + +@fixture(scope="module") +def replica_set( + openldap: OpenLDAP, + issuer_ca_configmap: str, + namespace: str, + ldap_mongodb_user: LDAPUser, +) -> MongoDB: + resource = MongoDB.from_yaml( + find_fixture("ldap/ldap-agent-auth.yaml"), namespace=namespace + ) + + secret_name = "bind-query-password" + create_secret(namespace, secret_name, {"password": openldap.admin_password}) + + resource["spec"]["security"]["authentication"]["ldap"] = { + "servers": [openldap.servers], + "bindQueryUser": "cn=admin,dc=example,dc=org", + "bindQueryPasswordSecretRef": {"name": secret_name}, + "validateLDAPServerConfig": True, + "caConfigMapRef": {"name": issuer_ca_configmap, "key": "ca-pem"}, + "userToDNMapping": '[{match: "(.+)",substitution: "uid={0},ou=groups,dc=example,dc=org"}]', + "authzQueryTemplate": "{USER}?memberOf?base", + } + + ac_secret_name = "automation-config-password" + create_secret( + namespace, ac_secret_name, {"automationConfigPassword": "LDAPPassword."} + ) + resource["spec"]["security"]["roles"] = [ + { + "role": "cn=users,ou=groups,dc=example,dc=org", + "db": "admin", + "privileges": [ + {"actions": ["insert"], "resource": {"db": "foo", "collection": "foo"}}, + ], + }, + ] + resource["spec"]["security"]["authentication"]["agents"] = { + "mode": "LDAP", + "automationPasswordSecretRef": { + "name": ac_secret_name, + "key": "automationConfigPassword", + }, + "automationUserName": "mms-automation-agent", + "automationLdapGroupDN": "cn=agents,ou=groups,dc=example,dc=org", + } + return resource.create() + + +@fixture(scope="module") +def ldap_user_mongodb( + replica_set: MongoDB, namespace: str, ldap_mongodb_user: LDAPUser +) -> MongoDBUser: + """Returns a list of MongoDBUsers (already created) and their corresponding passwords.""" + user = generic_user( + namespace, + username=ldap_mongodb_user.uid, + db="$external", + mongodb_resource=replica_set, + password=ldap_mongodb_user.password, + ) + + return user.create() + + +@mark.e2e_replica_set_ldap_group_dn +def test_replica_set( + replica_set: MongoDB, + ldap_mongodb_agent_user: LDAPUser, + ldap_user_mongodb: MongoDBUser, +): + replica_set.assert_reaches_phase(Phase.Running, timeout=400) + + +@mark.e2e_replica_set_ldap_group_dn +def test_new_ldap_users_can_authenticate( + replica_set: MongoDB, ldap_user_mongodb: MongoDBUser +): + tester = replica_set.tester() + + tester.assert_ldap_authentication( + username=ldap_user_mongodb["spec"]["username"], + password=ldap_user_mongodb.password, + db="foo", + collection="foo", + attempts=10, + ) + + +@mark.e2e_replica_set_ldap_group_dn +def test_deployment_is_reachable_with_ldap_agent( + replica_set: MongoDB, ldap_user_mongodb: MongoDBUser +): + tester = replica_set.tester() + # Due to what we found out in + # https://jira.mongodb.org/browse/CLOUDP-68873 + # the agents might report being in goal state, the MDB resource + # would report no errors but the deployment would be unreachable + # See the comment inside the function for further details + tester.assert_deployment_reachable(attempts=10) diff --git a/docker/mongodb-enterprise-tests/tests/authentication/replica_set_ldap_group_dn_with_x509_agent.py b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_ldap_group_dn_with_x509_agent.py new file mode 100644 index 000000000..3b1d6af58 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_ldap_group_dn_with_x509_agent.py @@ -0,0 +1,126 @@ +import random + +from pytest import mark, fixture + +from kubernetes import client +from kubernetes.client.rest import ApiException +from kubetester import create_secret, find_fixture +from kubetester import kubetester + +from kubetester.mongodb import MongoDB, Phase +from kubetester.mongodb_user import MongoDBUser, generic_user +from kubetester.ldap import OpenLDAP, LDAPUser +from kubetester.certs import ( + ISSUER_CA_NAME, + create_x509_mongodb_tls_certs, + create_x509_agent_tls_certs, +) +from datetime import datetime, timezone +import time + +MDB_RESOURCE = "ldap-replica-set" + + +@fixture(scope="module") +def agent_certs(issuer: str, namespace: str) -> str: + return create_x509_agent_tls_certs(issuer, namespace, MDB_RESOURCE) + + +@fixture(scope="module") +def replica_set( + openldap: OpenLDAP, + issuer_ca_configmap: str, + server_certs: str, + agent_certs: str, + namespace: str, +) -> MongoDB: + resource = MongoDB.from_yaml( + find_fixture("ldap/ldap-agent-auth.yaml"), namespace=namespace + ) + + secret_name = "bind-query-password" + create_secret(namespace, secret_name, {"password": openldap.admin_password}) + + resource["spec"]["security"]["authentication"]["ldap"] = { + "servers": [openldap.servers], + "bindQueryUser": "cn=admin,dc=example,dc=org", + "bindQueryPasswordSecretRef": {"name": secret_name}, + "validateLDAPServerConfig": True, + "caConfigMapRef": {"name": issuer_ca_configmap, "key": "ca-pem"}, + "userToDNMapping": '[{match: "CN=mms-automation-agent,(.+),L=NY,ST=NY,C=US", substitution: "uid=mms-automation-agent,{0},dc=example,dc=org"}, {match: "(.+)", substitution:"uid={0},ou=groups,dc=example,dc=org"}]', + "authzQueryTemplate": "{USER}?memberOf?base", + } + + resource["spec"]["security"]["tls"] = {"enabled": True, "ca": issuer_ca_configmap} + resource["spec"]["security"]["roles"] = [ + { + "role": "cn=users,ou=groups,dc=example,dc=org", + "db": "admin", + "privileges": [ + {"actions": ["insert"], "resource": {"db": "foo", "collection": "foo"}}, + ], + }, + ] + resource["spec"]["security"]["authentication"]["modes"] = ["LDAP", "SCRAM", "X509"] + resource["spec"]["security"]["authentication"]["agents"] = { + "mode": "X509", + "automationLdapGroupDN": f"cn=mms-automation-agent,ou={namespace},o=cluster.local-agent,dc=example,dc=org", + } + return resource.create() + + +@fixture(scope="module") +def ldap_user_mongodb( + replica_set: MongoDB, namespace: str, ldap_mongodb_user: LDAPUser +) -> MongoDBUser: + """Returns a list of MongoDBUsers (already created) and their corresponding passwords.""" + user = generic_user( + namespace, + username=ldap_mongodb_user.uid, + db="$external", + mongodb_resource=replica_set, + password=ldap_mongodb_user.password, + ) + + return user.create() + + +@fixture(scope="module") +def server_certs(issuer: str, namespace: str): + return create_x509_mongodb_tls_certs( + ISSUER_CA_NAME, namespace, MDB_RESOURCE, f"{MDB_RESOURCE}-cert" + ) + + +@mark.e2e_replica_set_ldap_group_dn_with_x509_agent +def test_replica_set( + replica_set: MongoDB, ldap_mongodb_x509_agent_user: LDAPUser, namespace: str +): + replica_set.assert_reaches_phase(Phase.Running, timeout=400) + + +@mark.e2e_replica_set_ldap_group_dn_with_x509_agent +def test_new_ldap_users_can_authenticate( + replica_set: MongoDB, ldap_user_mongodb: MongoDBUser, ca_path: str +): + tester = replica_set.tester() + + tester.assert_ldap_authentication( + username=ldap_user_mongodb["spec"]["username"], + password=ldap_user_mongodb.password, + db="foo", + collection="foo", + attempts=10, + ssl_ca_certs=ca_path, + ) + + +@mark.e2e_replica_set_ldap_group_dn_with_x509_agent +def test_deployment_is_reachable_with_ldap_agent(replica_set: MongoDB): + tester = replica_set.tester() + # Due to what we found out in + # https://jira.mongodb.org/browse/CLOUDP-68873 + # the agents might report being in goal state, the MDB resource + # would report no errors but the deployment would be unreachable + # See the comment inside the function for further details + tester.assert_deployment_reachable(attempts=10) diff --git a/docker/mongodb-enterprise-tests/tests/authentication/replica_set_ldap_tls.py b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_ldap_tls.py new file mode 100644 index 000000000..29d6511e9 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_ldap_tls.py @@ -0,0 +1,81 @@ +from pytest import mark, fixture + +from kubetester import create_secret, find_fixture + +from kubetester.mongodb import MongoDB, Phase +from kubetester.mongodb_user import MongoDBUser, generic_user, Role +from kubetester.ldap import OpenLDAP, LDAPUser, LDAP_AUTHENTICATION_MECHANISM + + +@fixture(scope="module") +def replica_set( + openldap_tls: OpenLDAP, + issuer_ca_configmap: str, + namespace: str, +) -> MongoDB: + resource = MongoDB.from_yaml( + find_fixture("ldap/ldap-replica-set.yaml"), namespace=namespace + ) + + secret_name = "bind-query-password" + create_secret(namespace, secret_name, {"password": openldap_tls.admin_password}) + + resource["spec"]["security"]["authentication"]["ldap"] = { + "servers": [openldap_tls.servers], + "bindQueryPasswordSecretRef": {"name": secret_name}, + "transportSecurity": "tls", + "validateLDAPServerConfig": True, + "caConfigMapRef": {"name": issuer_ca_configmap, "key": "ca-pem"}, + } + + return resource.create() + + +@fixture(scope="module") +def ldap_user_mongodb( + replica_set: MongoDB, namespace: str, ldap_mongodb_user_tls: LDAPUser +) -> MongoDBUser: + """Returns a list of MongoDBUsers (already created) and their corresponding passwords.""" + user = generic_user( + namespace, + username=ldap_mongodb_user_tls.username, + db="$external", + mongodb_resource=replica_set, + password=ldap_mongodb_user_tls.password, + ) + user.add_roles( + [ + Role(db="admin", role="clusterAdmin"), + Role(db="admin", role="readWriteAnyDatabase"), + Role(db="admin", role="dbAdminAnyDatabase"), + ] + ) + + return user.create() + + +@mark.e2e_replica_set_ldap_tls +def test_replica_set(replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running, timeout=400) + + +@mark.e2e_replica_set_ldap_tls +def test_create_ldap_user(replica_set: MongoDB, ldap_user_mongodb: MongoDBUser): + ldap_user_mongodb.assert_reaches_phase(Phase.Updated) + + ac = replica_set.get_automation_config_tester() + ac.assert_authentication_mechanism_enabled( + LDAP_AUTHENTICATION_MECHANISM, active_auth_mechanism=False + ) + ac.assert_expected_users(1) + + +@mark.e2e_replica_set_ldap_tls +def test_new_ldap_users_can_authenticate( + replica_set: MongoDB, ldap_user_mongodb: MongoDBUser +): + tester = replica_set.tester() + + tester.assert_ldap_authentication( + ldap_user_mongodb["spec"]["username"], ldap_user_mongodb.password, attempts=10 + ) diff --git a/docker/mongodb-enterprise-tests/tests/authentication/replica_set_ldap_user_to_dn_mapping.py b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_ldap_user_to_dn_mapping.py new file mode 100644 index 000000000..cd41c378c --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_ldap_user_to_dn_mapping.py @@ -0,0 +1,86 @@ +from pytest import mark, fixture + +from kubetester import create_secret, find_fixture + +from kubetester.mongodb import MongoDB, Phase +from kubetester.mongodb_user import MongoDBUser, generic_user, Role +from kubetester.ldap import OpenLDAP, LDAPUser, LDAP_AUTHENTICATION_MECHANISM + + +@fixture(scope="module") +def replica_set( + openldap: OpenLDAP, + issuer_ca_configmap: str, + namespace: str, + ldap_mongodb_user: LDAPUser, +) -> MongoDB: + resource = MongoDB.from_yaml( + find_fixture("ldap/ldap-replica-set.yaml"), namespace=namespace + ) + + secret_name = "bind-query-password" + create_secret(namespace, secret_name, {"password": openldap.admin_password}) + + resource["spec"]["security"]["authentication"]["ldap"] = { + "servers": [openldap.servers], + "bindQueryUser": "cn=admin,dc=example,dc=org", + "bindQueryPasswordSecretRef": {"name": secret_name}, + "validateLDAPServerConfig": True, + "caConfigMapRef": {"name": issuer_ca_configmap, "key": "ca-pem"}, + "userToDNMapping": '[{match: "(.+)",substitution: "uid={0},ou=groups,dc=example,dc=org"}]', + } + + return resource.create() + + +@fixture(scope="module") +def ldap_user_mongodb( + replica_set: MongoDB, + namespace: str, + ldap_mongodb_user: LDAPUser, + openldap: OpenLDAP, +) -> MongoDBUser: + """Returns a list of MongoDBUsers (already created) and their corresponding passwords.""" + user = generic_user( + namespace, + username=ldap_mongodb_user.uid, + db="$external", + mongodb_resource=replica_set, + password=ldap_mongodb_user.password, + ) + user.add_roles( + [ + Role(db="admin", role="clusterAdmin"), + Role(db="admin", role="readWriteAnyDatabase"), + Role(db="admin", role="dbAdminAnyDatabase"), + ] + ) + + return user.create() + + +@mark.e2e_replica_set_ldap_user_to_dn_mapping +def test_replica_set(replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running, timeout=400) + + +@mark.e2e_replica_set_ldap_user_to_dn_mapping +def test_create_ldap_user(replica_set: MongoDB, ldap_user_mongodb: MongoDBUser): + ldap_user_mongodb.assert_reaches_phase(Phase.Updated) + + ac = replica_set.get_automation_config_tester() + ac.assert_authentication_mechanism_enabled( + LDAP_AUTHENTICATION_MECHANISM, active_auth_mechanism=False + ) + ac.assert_expected_users(1) + + +@mark.e2e_replica_set_ldap_user_to_dn_mapping +def test_new_ldap_users_can_authenticate( + replica_set: MongoDB, ldap_user_mongodb: MongoDBUser +): + tester = replica_set.tester() + + tester.assert_ldap_authentication( + ldap_user_mongodb["spec"]["username"], ldap_user_mongodb.password, attempts=10 + ) diff --git a/docker/mongodb-enterprise-tests/tests/authentication/replica_set_scram_sha_1_connectivity.py b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_scram_sha_1_connectivity.py new file mode 100644 index 000000000..97372b91b --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_scram_sha_1_connectivity.py @@ -0,0 +1,20 @@ +import pytest +from pytest import fixture + +from kubetester.mongotester import ReplicaSetTester +from tests.authentication.sha1_connectivity_tests import SHA1ConnectivityTests + + +@pytest.mark.e2e_replica_set_scram_sha_1_user_connectivity +class TestReplicaSetSHA1Connectivity(SHA1ConnectivityTests): + @fixture + def yaml_file(self): + return "replica-set-explicit-scram-sha-1.yaml" + + @fixture + def mdb_resource_name(self): + return "replica-set-scram-sha-1" + + @fixture + def mongo_tester(self, mdb_resource_name: str): + return ReplicaSetTester(mdb_resource_name, 3) diff --git a/docker/mongodb-enterprise-tests/tests/authentication/replica_set_scram_sha_256_connectivity.py b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_scram_sha_256_connectivity.py new file mode 100644 index 000000000..81a3bc67f --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_scram_sha_256_connectivity.py @@ -0,0 +1,212 @@ +from typing import Dict + +from kubetester import ( + create_secret, + find_fixture, + read_secret, + update_secret, + create_or_update, + create_or_update_secret, +) +from kubetester.kubetester import KubernetesTester +from kubetester.mongodb import MongoDB, Phase +from kubetester.mongodb_user import MongoDBUser +from pytest import fixture, mark + +MDB_RESOURCE = "my-replica-set" +USER_NAME = "mms-user-1" +PASSWORD_SECRET_NAME = "mms-user-1-password" +CONNECTION_STRING_SECRET_NAME = "my-replica-set-connection-string" +USER_PASSWORD = "my-password" +USER_DATABASE = "admin" + + +@fixture(scope="module") +def replica_set(namespace: str) -> MongoDB: + resource = MongoDB.from_yaml( + find_fixture("replica-set-scram-sha-256.yaml"), + namespace=namespace, + name=MDB_RESOURCE, + ) + + resource["spec"]["security"]["authentication"] = { + "ignoreUnknownUsers": True, + "enabled": True, + "modes": ["SCRAM"], + } + + return create_or_update(resource) + + +@fixture(scope="module") +def scram_user(namespace: str) -> MongoDBUser: + resource = MongoDBUser.from_yaml(find_fixture("scram-sha-user.yaml"), namespace=namespace) + + create_or_update_secret( + KubernetesTester.get_namespace(), + resource.get_secret_name(), + {"password": USER_PASSWORD}, + ) + + return create_or_update(resource) + + +@fixture(scope="module") +def standard_secret(replica_set: MongoDB): + secret_name = "{}-{}-{}".format(replica_set.name, USER_NAME, USER_DATABASE) + return read_secret(replica_set.namespace, secret_name) + + +@fixture(scope="module") +def connection_string_secret(replica_set: MongoDB): + return read_secret(replica_set.namespace, CONNECTION_STRING_SECRET_NAME) + + +@mark.e2e_replica_set_scram_sha_256_user_connectivity +class TestReplicaSetCreation(KubernetesTester): + def test_replica_set_created(self, replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running, timeout=400) + + def test_replica_set_connectivity(self, replica_set: MongoDB): + replica_set.assert_connectivity() + + def test_ops_manager_state_correctly_updated(self, replica_set: MongoDB): + tester = replica_set.get_automation_config_tester() + + tester.assert_authentication_mechanism_enabled("SCRAM-SHA-256") + tester.assert_authentication_enabled() + tester.assert_expected_users(0) + tester.assert_authoritative_set(False) + + +@mark.e2e_replica_set_scram_sha_256_user_connectivity +def test_create_user(scram_user: MongoDBUser): + scram_user.assert_reaches_phase(Phase.Updated) + + +@mark.e2e_replica_set_scram_sha_256_user_connectivity +class TestReplicaSetIsUpdatedWithNewUser(KubernetesTester): + def test_replica_set_connectivity(self, replica_set: MongoDB): + replica_set.assert_connectivity() + + def test_ops_manager_state_correctly_updated(self, replica_set: MongoDB): + expected_roles = { + (USER_DATABASE, "clusterAdmin"), + (USER_DATABASE, "userAdminAnyDatabase"), + (USER_DATABASE, "readWrite"), + (USER_DATABASE, "userAdminAnyDatabase"), + } + + tester = replica_set.get_automation_config_tester() + tester.assert_has_user(USER_NAME) + tester.assert_user_has_roles(USER_NAME, expected_roles) + tester.assert_authentication_mechanism_enabled("SCRAM-SHA-256") + tester.assert_authentication_enabled() + tester.assert_expected_users(1) + tester.assert_authoritative_set(False) + + def test_user_can_authenticate_with_correct_password(self, replica_set: MongoDB): + replica_set.tester().assert_scram_sha_authentication( + password="my-password", + username="mms-user-1", + auth_mechanism="SCRAM-SHA-256", + ) + + def test_user_cannot_authenticate_with_incorrect_password(self, replica_set: MongoDB): + replica_set.tester().assert_scram_sha_authentication_fails( + password="invalid-password", + username="mms-user-1", + auth_mechanism="SCRAM-SHA-256", + ) + + +@mark.e2e_replica_set_scram_sha_256_user_connectivity +class TestCanChangePassword(KubernetesTester): + def test_user_can_authenticate_with_new_password(self, namespace: str, replica_set: MongoDB): + update_secret(namespace, PASSWORD_SECRET_NAME, {"password": "my-new-password7"}) + replica_set.tester().assert_scram_sha_authentication( + password="my-new-password7", + username="mms-user-1", + auth_mechanism="SCRAM-SHA-256", + ) + + def test_user_cannot_authenticate_with_old_password(self, replica_set: MongoDB): + replica_set.tester().assert_scram_sha_authentication_fails( + password="my-password", + username="mms-user-1", + auth_mechanism="SCRAM-SHA-256", + ) + + +@mark.e2e_replica_set_scram_sha_256_user_connectivity +def test_credentials_secret_is_created(replica_set: MongoDB, standard_secret: Dict[str, str]): + assert "username" in standard_secret + assert "password" in standard_secret + assert "connectionString.standard" in standard_secret + assert "connectionString.standardSrv" in standard_secret + + +@mark.e2e_replica_set_scram_sha_256_user_connectivity +def test_credentials_can_connect_to_db(replica_set: MongoDB, standard_secret: Dict[str, str]): + print("Connecting with {}".format(standard_secret["connectionString.standard"])) + replica_set.assert_connectivity_from_connection_string(standard_secret["connectionString.standard"], tls=False) + + +@mark.e2e_replica_set_scram_sha_256_user_connectivity +def test_credentials_can_connect_to_db_with_srv(replica_set: MongoDB, standard_secret: Dict[str, str]): + print("Connecting with {}".format(standard_secret["connectionString.standardSrv"])) + replica_set.assert_connectivity_from_connection_string(standard_secret["connectionString.standardSrv"], tls=False) + + +@mark.e2e_replica_set_scram_sha_256_user_connectivity +def test_update_user_with_connection_string_secret(scram_user: MongoDBUser): + scram_user.load() + scram_user["spec"]["connectionStringSecretName"] = CONNECTION_STRING_SECRET_NAME + scram_user.update() + + scram_user.assert_reaches_phase(Phase.Updated) + + +@mark.e2e_replica_set_scram_sha_256_user_connectivity +def test_credentials_can_connect_to_db_with_connection_string_secret( + replica_set: MongoDB, connection_string_secret: Dict[str, str] +): + print("Connecting with {}".format(connection_string_secret["connectionString.standard"])) + replica_set.assert_connectivity_from_connection_string( + connection_string_secret["connectionString.standard"], tls=False + ) + + print("Connecting with {}".format(connection_string_secret["connectionString.standardSrv"])) + replica_set.assert_connectivity_from_connection_string( + connection_string_secret["connectionString.standardSrv"], tls=False + ) + + +@mark.e2e_replica_set_scram_sha_256_user_connectivity +def test_authentication_is_still_configured_after_remove_authentication(namespace: str, replica_set: MongoDB): + replica_set["spec"]["security"]["authentication"] = None + replica_set.update() + replica_set.assert_reaches_phase(Phase.Running, timeout=600) + + tester = replica_set.get_automation_config_tester() + # authentication remains enabled as the operator is not configuring it when + # spec.security.authentication is not configured + tester.assert_has_user(USER_NAME) + tester.assert_authentication_mechanism_enabled("SCRAM-SHA-256") + tester.assert_authentication_enabled() + tester.assert_expected_users(1) + tester.assert_authoritative_set(False) + + +@mark.e2e_replica_set_scram_sha_256_user_connectivity +def test_authentication_can_be_disabled_without_modes(namespace: str, replica_set: MongoDB): + replica_set["spec"]["security"]["authentication"] = { + "enabled": False, + } + replica_set.update() + replica_set.assert_reaches_phase(Phase.Running, timeout=600) + + tester = replica_set.get_automation_config_tester() + # we have explicitly set authentication to be disabled + tester.assert_has_user(USER_NAME) + tester.assert_authentication_disabled(remaining_users=1) diff --git a/docker/mongodb-enterprise-tests/tests/authentication/replica_set_scram_sha_256_user_first.py b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_scram_sha_256_user_first.py new file mode 100644 index 000000000..0a7e56264 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_scram_sha_256_user_first.py @@ -0,0 +1,85 @@ +import pytest +from kubetester.kubetester import ( + fixture as yaml_fixture, + KubernetesTester, +) +from kubetester.mongodb import MongoDB, Phase +from kubetester.mongodb_user import MongoDBUser +from pytest import fixture + +MDB_RESOURCE = "my-replica-set" +USER_NAME = "mms-user-1" +PASSWORD_SECRET_NAME = "mms-user-1-password" +USER_PASSWORD = "my-password" + + +@fixture(scope="module") +def scram_user(namespace) -> MongoDBUser: + """Creates a password secret and then the user referencing it""" + resource = MongoDBUser.from_yaml( + yaml_fixture("scram-sha-user.yaml"), namespace=namespace + ) + + print( + f"\nCreating password for MongoDBUser {resource.name} in secret/{resource.get_secret_name()} " + ) + KubernetesTester.create_secret( + KubernetesTester.get_namespace(), + resource.get_secret_name(), + { + "password": USER_PASSWORD, + }, + ) + + yield resource.create() + + +@fixture(scope="module") +def replica_set(namespace: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-basic.yaml"), + namespace=namespace, + name="my-replica-set", + ) + + return resource.create() + + +@pytest.mark.e2e_replica_set_scram_sha_256_user_first +def test_replica_set_created(replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running) + + +@pytest.mark.e2e_replica_set_scram_sha_256_user_first +def test_user_pending(scram_user: MongoDBUser): + """pending phase as auth has not yet been enabled""" + scram_user.assert_reaches_phase(Phase.Pending, timeout=50) + + +@pytest.mark.e2e_replica_set_scram_sha_256_user_first +def test_replica_set_auth_enabled(replica_set: MongoDB): + replica_set["spec"]["security"] = { + "authentication": {"enabled": True, "modes": ["SCRAM"]} + } + replica_set.update() + replica_set.assert_abandons_phase(Phase.Running) + replica_set.assert_reaches_phase(Phase.Running, timeout=400) + + +@pytest.mark.e2e_replica_set_scram_sha_256_user_first +def test_user_created(scram_user: MongoDBUser): + scram_user.assert_reaches_phase(Phase.Updated, timeout=50) + + +@pytest.mark.e2e_replica_set_scram_sha_256_user_first +def test_replica_set_connectivity(replica_set: MongoDB): + replica_set.assert_connectivity() + + +@pytest.mark.e2e_replica_set_scram_sha_256_user_first +def test_ops_manager_state_correctly_updated(replica_set: MongoDB): + tester = replica_set.get_automation_config_tester() + tester.assert_authentication_mechanism_enabled("SCRAM-SHA-256") + tester.assert_authentication_enabled() + tester.assert_expected_users(1) + tester.assert_authoritative_set(True) diff --git a/docker/mongodb-enterprise-tests/tests/authentication/replica_set_scram_sha_and_x509.py b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_scram_sha_and_x509.py new file mode 100644 index 000000000..1b95b797c --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_scram_sha_and_x509.py @@ -0,0 +1,182 @@ +import tempfile + +import pytest + +from kubetester.automation_config_tester import AutomationConfigTester +from kubetester.certs import ( + ISSUER_CA_NAME, + create_mongodb_tls_certs, + create_x509_user_cert, +) +from kubetester.kubetester import KubernetesTester +from kubetester.kubetester import fixture as load_fixture +from kubetester.mongodb import MongoDB, Phase +from kubetester.mongotester import ReplicaSetTester + +MDB_RESOURCE = "replica-set-scram-256-and-x509" +USER_NAME = "mms-user-1" +PASSWORD_SECRET_NAME = "mms-user-1-password" +USER_PASSWORD = "my-password" + + +@pytest.fixture(scope="module") +def replica_set(namespace: str, issuer_ca_configmap: str, server_certs: str) -> MongoDB: + res = MongoDB.from_yaml( + load_fixture("replica-set-tls-scram-sha-256.yaml"), namespace=namespace + ) + res["spec"]["security"]["tls"]["ca"] = issuer_ca_configmap + return res.create() + + +@pytest.fixture(scope="module") +def server_certs(issuer: str, namespace: str): + return create_mongodb_tls_certs( + ISSUER_CA_NAME, namespace, MDB_RESOURCE, f"{MDB_RESOURCE}-cert" + ) + + +@pytest.mark.e2e_replica_set_scram_sha_and_x509 +class TestReplicaSetCreation(KubernetesTester): + def test_replica_set_running(self, replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running, timeout=400) + + def test_replica_set_connectivity(self, replica_set: MongoDB, ca_path: str): + tester = replica_set.tester(use_ssl=True, ca_path=ca_path) + tester.assert_connectivity() + + def test_ops_manager_state_correctly_updated(self): + tester = AutomationConfigTester(KubernetesTester.get_automation_config()) + tester.assert_authentication_mechanism_enabled("SCRAM-SHA-256") + tester.assert_authentication_enabled() + + +@pytest.mark.e2e_replica_set_scram_sha_and_x509 +class TestCreateMongoDBUser(KubernetesTester): + """ + description: | + Creates a MongoDBUser + create: + file: scram-sha-user.yaml + patch: '[{"op":"replace","path":"/spec/mongodbResourceRef/name","value": "replica-set-scram-256-and-x509" }]' + wait_until: in_updated_state + timeout: 150 + """ + + @classmethod + def setup_class(cls): + print( + f"creating password for MongoDBUser {USER_NAME} in secret/{PASSWORD_SECRET_NAME} " + ) + KubernetesTester.create_secret( + KubernetesTester.get_namespace(), + PASSWORD_SECRET_NAME, + { + "password": USER_PASSWORD, + }, + ) + super().setup_class() + + def test_create_user(self): + pass + + +@pytest.mark.e2e_replica_set_scram_sha_and_x509 +class TestScramUserCanAuthenticate(KubernetesTester): + def test_user_cannot_authenticate_with_incorrect_password(self, ca_path: str): + tester = ReplicaSetTester(MDB_RESOURCE, 3) + tester.assert_scram_sha_authentication_fails( + password="invalid-password", + username="mms-user-1", + ssl=True, + auth_mechanism="SCRAM-SHA-256", + ssl_ca_certs=ca_path, + ) + + def test_user_can_authenticate_with_correct_password(self, ca_path): + tester = ReplicaSetTester(MDB_RESOURCE, 3) + tester.assert_scram_sha_authentication( + password="my-password", + username="mms-user-1", + ssl=True, + auth_mechanism="SCRAM-SHA-256", + ssl_ca_certs=ca_path, + ) + + def test_enable_x509(self, replica_set: MongoDB): + replica_set.load() + replica_set["spec"]["security"]["authentication"]["modes"].append("X509") + replica_set["spec"]["security"]["authentication"]["agents"] = {"mode": "SCRAM"} + replica_set.update() + replica_set.assert_abandons_phase(Phase.Running, timeout=50) + replica_set.assert_reaches_phase(Phase.Running, timeout=600) + + def test_automation_config_was_updated(self): + tester = AutomationConfigTester(KubernetesTester.get_automation_config()) + # when both agents.mode is set to SCRAM, X509 should not be used as agent auth + tester.assert_authentication_mechanism_enabled( + "MONGODB-X509", active_auth_mechanism=False + ) + tester.assert_authentication_mechanism_enabled("SCRAM-SHA-256") + tester.assert_authentication_enabled(expected_num_deployment_auth_mechanisms=2) + + tester.assert_expected_users(1) + + +@pytest.mark.e2e_replica_set_scram_sha_and_x509 +class TestAddMongoDBUser(KubernetesTester): + """ + create: + file: test-x509-user.yaml + patch: '[{"op":"replace","path":"/spec/mongodbResourceRef/name","value": "replica-set-scram-256-and-x509" }]' + wait_until: user_exists + """ + + def test_add_user(self): + assert True + + @staticmethod + def user_exists(): + ac = KubernetesTester.get_automation_config() + users = ac["auth"]["usersWanted"] + return "CN=x509-testing-user" in [user["user"] for user in users] + + +@pytest.mark.e2e_replica_set_scram_sha_and_x509 +class TestX509CertCreationAndApproval(KubernetesTester): + def setup(self): + self.cert_file = tempfile.NamedTemporaryFile(delete=False, mode="w") + + def test_create_user_and_authenticate( + self, issuer: str, namespace: str, ca_path: str + ): + create_x509_user_cert(issuer, namespace, path=self.cert_file.name) + tester = ReplicaSetTester(MDB_RESOURCE, 3) + tester.assert_x509_authentication( + cert_file_name=self.cert_file.name, ssl_ca_certs=ca_path + ) + + def teardown(self): + self.cert_file.close() + + +@pytest.mark.e2e_replica_set_scram_sha_and_x509 +class TestCanStillAuthAsScramUsers(KubernetesTester): + def test_user_cannot_authenticate_with_incorrect_password(self, ca_path: str): + tester = ReplicaSetTester(MDB_RESOURCE, 3) + tester.assert_scram_sha_authentication_fails( + password="invalid-password", + username="mms-user-1", + ssl=True, + auth_mechanism="SCRAM-SHA-256", + ssl_ca_certs=ca_path, + ) + + def test_user_can_authenticate_with_correct_password(self, ca_path: str): + tester = ReplicaSetTester(MDB_RESOURCE, 3) + tester.assert_scram_sha_authentication( + password="my-password", + username="mms-user-1", + ssl=True, + auth_mechanism="SCRAM-SHA-256", + ssl_ca_certs=ca_path, + ) diff --git a/docker/mongodb-enterprise-tests/tests/authentication/replica_set_scram_sha_upgrade.py b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_scram_sha_upgrade.py new file mode 100644 index 000000000..c6e3676e6 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_scram_sha_upgrade.py @@ -0,0 +1,54 @@ +import pytest + +from kubetester.kubetester import KubernetesTester +from kubetester.mongotester import ReplicaSetTester +from kubetester.automation_config_tester import AutomationConfigTester + +MDB_RESOURCE = "my-replica-set-scram" + + +@pytest.mark.e2e_replica_set_scram_sha_1_upgrade +class TestCreateScramSha1ReplicaSet(KubernetesTester): + """ + description: | + Creates a Replica Set with SCRAM authentication. Defaulting to sha 256 if non-specific provided. + create: + file: replica-set-scram.yaml + wait_until: in_running_state + """ + + def test_assert_connectivity(self): + ReplicaSetTester(MDB_RESOURCE, 3).assert_connectivity() + + def test_ops_manager_state_updated_correctly(self): + tester = AutomationConfigTester(KubernetesTester.get_automation_config()) + tester.assert_authentication_mechanism_enabled("SCRAM-SHA-256") + tester.assert_authentication_enabled() + + tester.assert_expected_users(0) + tester.assert_authoritative_set(True) + + +@pytest.mark.e2e_replica_set_scram_sha_1_upgrade +class TestReplicaSetDeleted(KubernetesTester): + """ + description: | + Deletes the Replica Set. + delete: + file: replica-set-scram.yaml + wait_until: mongo_resource_deleted + timeout: 120 + """ + + def test_authentication_was_disabled(self): + def authentication_was_disabled(): + tester = AutomationConfigTester(KubernetesTester.get_automation_config()) + try: + tester.assert_authentication_disabled() + return True + except AssertionError: + return False + + KubernetesTester.wait_until( + authentication_was_disabled, timeout=10, sleep_time=1 + ) diff --git a/docker/mongodb-enterprise-tests/tests/authentication/replica_set_scram_x509_ic_manual_certs.py b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_scram_x509_ic_manual_certs.py new file mode 100644 index 000000000..96b3aec22 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_scram_x509_ic_manual_certs.py @@ -0,0 +1,84 @@ +from kubetester.certs import ( + create_mongodb_tls_certs, + SetProperties, +) +from kubetester.mongodb import MongoDB, Phase + +from kubetester.kubetester import fixture as _fixture +from pytest import mark, fixture + +MDB_RESOURCE = "my-replica-set" +SUBJECT = {"organizations": ["MDB Tests"], "organizationalUnits": ["Servers"]} +SERVER_SET = SetProperties(MDB_RESOURCE, MDB_RESOURCE + "-svc", 3) + + +@fixture(scope="module") +def all_certs(issuer, namespace) -> None: + """Generates TLS Certificates for the servers.""" + spec_server = { + "subject": SUBJECT, + "usages": ["server auth"], + } + + spec_client = { + "subject": SUBJECT, + "usages": ["client auth"], + } + + server_set = SERVER_SET + create_mongodb_tls_certs( + issuer, + namespace, + server_set.name, + server_set.name + "-cert", + server_set.replicas, + server_set.service, + spec_server, + ) + create_mongodb_tls_certs( + issuer, + namespace, + server_set.name, + server_set.name + "-clusterfile", + server_set.replicas, + server_set.service, + spec_client, + ) + + +@fixture(scope="module") +def replica_set( + namespace: str, + all_certs, + issuer_ca_configmap: str, +) -> MongoDB: + _ = all_certs + mdb: MongoDB = MongoDB.from_yaml( + _fixture("replica-set-scram-sha-256-x509-internal-cluster.yaml"), + namespace=namespace, + ) + mdb["spec"]["security"]["tls"]["ca"] = issuer_ca_configmap + return mdb.create() + + +@mark.e2e_replica_set_scram_x509_ic_manual_certs +def test_create_replica_set_with_x509_internal_cluster(replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running) + + +@mark.e2e_replica_set_scram_x509_ic_manual_certs +def test_create_replica_can_connect(replica_set: MongoDB, ca_path: str): + # The ca_path fixture indicates a relative path in the testing Pod to the + # CA file we can use to validate against the certificates generated + # by cert-manager. + replica_set.assert_connectivity(ca_path=ca_path) + + +@mark.e2e_replica_set_scram_x509_ic_manual_certs +def test_ops_manager_state_was_updated_correctly(replica_set: MongoDB): + ac_tester = replica_set.get_automation_config_tester() + ac_tester.assert_authentication_enabled(expected_num_deployment_auth_mechanisms=2) + ac_tester.assert_authentication_mechanism_enabled("SCRAM-SHA-256") + ac_tester.assert_expected_users(0) + ac_tester.assert_authoritative_set(True) + ac_tester.assert_internal_cluster_authentication_enabled() diff --git a/docker/mongodb-enterprise-tests/tests/authentication/replica_set_scram_x509_internal_cluster.py b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_scram_x509_internal_cluster.py new file mode 100644 index 000000000..2a5fc5e1e --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_scram_x509_internal_cluster.py @@ -0,0 +1,52 @@ +from pytest import mark, fixture + +from kubetester import create_or_update, create_or_update_secret, read_secret +from kubetester.automation_config_tester import AutomationConfigTester +from kubetester.certs import ( + ISSUER_CA_NAME, + create_x509_mongodb_tls_certs, + create_x509_agent_tls_certs, +) +from kubetester.kubetester import KubernetesTester +from kubetester.kubetester import fixture as load_fixture +from kubetester.mongodb import MongoDB, Phase + +MDB_RESOURCE = "my-replica-set" + + +@fixture(scope="module") +def server_certs(issuer: str, namespace: str): + create_x509_mongodb_tls_certs(ISSUER_CA_NAME, namespace, MDB_RESOURCE, f"{MDB_RESOURCE}-cert") + secret_name = f"{MDB_RESOURCE}-cert" + data = read_secret(namespace, secret_name) + secret_type = "kubernetes.io/tls" + create_or_update_secret(namespace, f"{MDB_RESOURCE}-clusterfile", data, type=secret_type) + + +@fixture(scope="module") +def agent_certs(issuer: str, namespace: str) -> str: + return create_x509_agent_tls_certs(issuer, namespace, MDB_RESOURCE) + + +@fixture(scope="module") +def mdb(namespace: str, server_certs: str, agent_certs: str, issuer_ca_configmap: str) -> MongoDB: + res = MongoDB.from_yaml( + load_fixture("replica-set-scram-sha-256-x509-internal-cluster.yaml"), + namespace=namespace, + ) + res["spec"]["security"]["tls"]["ca"] = issuer_ca_configmap + return create_or_update(res) + + +@mark.e2e_replica_set_scram_x509_internal_cluster +class TestReplicaSetScramX509Internal(KubernetesTester): + def test_mdb_is_running(self, mdb: MongoDB): + mdb.assert_reaches_phase(Phase.Running, timeout=600) + + def test_ops_manager_state_was_updated_correctly(self): + ac_tester = AutomationConfigTester(self.get_automation_config()) + ac_tester.assert_authentication_enabled() + ac_tester.assert_authentication_mechanism_enabled("SCRAM-SHA-256") + ac_tester.assert_expected_users(0) + ac_tester.assert_authoritative_set(True) + ac_tester.assert_internal_cluster_authentication_enabled() diff --git a/docker/mongodb-enterprise-tests/tests/authentication/replica_set_update_roles_no_privileges.py b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_update_roles_no_privileges.py new file mode 100644 index 000000000..9111bcb7c --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_update_roles_no_privileges.py @@ -0,0 +1,134 @@ +from pytest import mark, fixture +from kubetester import create_secret, find_fixture, wait_until +from kubetester.mongodb import MongoDB, Phase +from kubetester.mongodb_user import MongoDBUser, generic_user +from kubetester.ldap import OpenLDAP, LDAPUser, LDAP_AUTHENTICATION_MECHANISM + + +@fixture(scope="module") +def replica_set( + openldap: OpenLDAP, + issuer_ca_configmap: str, + namespace: str, + ldap_mongodb_user: LDAPUser, +) -> MongoDB: + resource = MongoDB.from_yaml( + find_fixture("ldap/ldap-replica-set-roles.yaml"), namespace=namespace + ) + + secret_name = "bind-query-password" + create_secret(namespace, secret_name, {"password": openldap.admin_password}) + + resource["spec"]["security"]["authentication"]["ldap"] = { + "servers": [openldap.servers], + "bindQueryUser": "cn=admin,dc=example,dc=org", + "bindQueryPasswordSecretRef": {"name": secret_name}, + "validateLDAPServerConfig": True, + "caConfigMapRef": {"name": issuer_ca_configmap, "key": "ca-pem"}, + "userToDNMapping": '[{match: "(.+)",substitution: "uid={0},ou=groups,dc=example,dc=org"}]', + "authzQueryTemplate": "{USER}?memberOf?base", + } + + yield resource.create() + resource.delete() + + +@fixture(scope="module") +def ldap_user_mongodb( + replica_set: MongoDB, + namespace: str, + ldap_mongodb_user: LDAPUser, + openldap: OpenLDAP, +) -> MongoDBUser: + """Returns a list of MongoDBUsers (already created) and their corresponding passwords.""" + user = generic_user( + namespace, + username=ldap_mongodb_user.uid, + db="$external", + mongodb_resource=replica_set, + password=ldap_mongodb_user.password, + ) + + yield user.create() + user.delete() + + +@mark.e2e_replica_set_update_roles_no_privileges +def test_replica_set(replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running, timeout=400) + + +@mark.e2e_replica_set_update_roles_no_privileges +def test_create_ldap_user(replica_set: MongoDB, ldap_user_mongodb: MongoDBUser): + ldap_user_mongodb.assert_reaches_phase(Phase.Updated) + + ac = replica_set.get_automation_config_tester() + ac.assert_authentication_mechanism_enabled( + LDAP_AUTHENTICATION_MECHANISM, active_auth_mechanism=False + ) + ac.assert_expected_users(1) + + +@mark.e2e_replica_set_update_roles_no_privileges +def test_new_ldap_users_can_write_to_database( + replica_set: MongoDB, ldap_user_mongodb: MongoDBUser +): + tester = replica_set.tester() + + tester.assert_ldap_authentication( + username=ldap_user_mongodb["spec"]["username"], + password=ldap_user_mongodb.password, + db="foo", + collection="foo", + attempts=10, + ) + + +@mark.e2e_replica_set_update_roles_no_privileges +def test_automation_config_has_roles(replica_set: MongoDB): + tester = replica_set.get_automation_config_tester() + + tester.assert_has_expected_number_of_roles(expected_roles=1) + role = { + "role": "cn=users,ou=groups,dc=example,dc=org", + "db": "admin", + "privileges": [ + {"actions": ["insert"], "resource": {"collection": "foo", "db": "foo"}}, + { + "actions": ["insert", "find"], + "resource": {"collection": "", "db": "admin"}, + }, + ], + "authenticationRestrictions": [], + } + tester.assert_expected_role(role_index=0, expected_value=role) + + +@mark.e2e_replica_set_update_roles_no_privileges +def test_update_role(replica_set: MongoDB): + replica_set.load() + replica_set["spec"]["security"]["roles"] = [ + { + "db": "admin", + "role": "cn=users,ou=groups,dc=example,dc=org", + "roles": [{"db": "admin", "role": "readWriteAnyDatabase"}], + } + ] + replica_set.update() + + +@mark.e2e_replica_set_update_roles_no_privileges +def test_automation_config_has_new_roles(replica_set: MongoDB): + role = { + "role": "cn=users,ou=groups,dc=example,dc=org", + "db": "admin", + "privileges": [], + "roles": [{"db": "admin", "role": "readWriteAnyDatabase"}], + "authenticationRestrictions": [], + } + + def has_role() -> bool: + tester = replica_set.get_automation_config_tester() + return tester.get_role_at_index(0) == role + + wait_until(has_role, timeout=90, sleep_time=5) diff --git a/docker/mongodb-enterprise-tests/tests/authentication/replica_set_x509_to_scram_transition.py b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_x509_to_scram_transition.py new file mode 100644 index 000000000..7a63ad179 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/replica_set_x509_to_scram_transition.py @@ -0,0 +1,187 @@ +import pytest + +from kubetester import create_or_update +from kubetester.omtester import get_rs_cert_names +from kubetester.automation_config_tester import AutomationConfigTester +from kubetester.kubetester import KubernetesTester +from kubetester.mongotester import ReplicaSetTester + +from kubetester.mongodb import MongoDB, Phase +from pytest import fixture +from kubetester.kubetester import fixture as load_fixture +from kubetester.certs import ( + ISSUER_CA_NAME, + create_mongodb_tls_certs, + create_agent_tls_certs, +) + +MDB_RESOURCE = "replica-set-x509-to-scram-256" +USER_NAME = "mms-user-1" +PASSWORD_SECRET_NAME = "mms-user-1-password" +USER_PASSWORD = "my-password" + + +@fixture(scope="module") +def replica_set(namespace: str, server_certs: str, agent_certs: str, issuer_ca_configmap: str) -> MongoDB: + res = MongoDB.from_yaml(load_fixture("replica-set-x509-to-scram-256.yaml"), namespace=namespace) + res["spec"]["security"]["tls"]["ca"] = issuer_ca_configmap + return create_or_update(res) + + +@pytest.fixture(scope="module") +def server_certs(issuer: str, namespace: str): + return create_mongodb_tls_certs(ISSUER_CA_NAME, namespace, MDB_RESOURCE, f"{MDB_RESOURCE}-cert") + + +@pytest.fixture(scope="module") +def agent_certs(issuer: str, namespace: str) -> str: + return create_agent_tls_certs(issuer, namespace, MDB_RESOURCE) + + +@pytest.mark.e2e_replica_set_x509_to_scram_transition +class TestEnableX509ForReplicaSet(KubernetesTester): + def test_replica_set_running(self, replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running, timeout=400) + + def test_ops_manager_state_updated_correctly(self): + tester = AutomationConfigTester(KubernetesTester.get_automation_config()) + tester.assert_authentication_mechanism_enabled("MONGODB-X509") + tester.assert_authoritative_set(True) + tester.assert_authentication_enabled() + tester.assert_expected_users(0) + + def test_deployment_is_reachable(self, replica_set: MongoDB): + tester = replica_set.tester() + # Due to what we found out in + # https://jira.mongodb.org/browse/CLOUDP-68873 + # the agents might report being in goal state, the MDB resource + # would report no errors but the deployment would be unreachable + # See the comment inside the function for further details + tester.assert_deployment_reachable(attempts=10) + + +@pytest.mark.e2e_replica_set_x509_to_scram_transition +def test_enable_scram_and_x509(replica_set: MongoDB): + replica_set.load() + replica_set["spec"]["security"]["authentication"]["modes"] = ["X509", "SCRAM"] + replica_set.update() + replica_set.assert_abandons_phase(Phase.Running, timeout=100) + replica_set.assert_reaches_phase(Phase.Running, timeout=900) + + +@pytest.mark.e2e_replica_set_x509_to_scram_transition +def test_x509_is_still_configured(replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running, timeout=300) + tester = AutomationConfigTester(KubernetesTester.get_automation_config()) + tester.assert_authentication_mechanism_enabled("MONGODB-X509") + tester.assert_authoritative_set(True) + tester.assert_authentication_mechanism_enabled("SCRAM-SHA-256", active_auth_mechanism=False) + tester.assert_authentication_enabled(expected_num_deployment_auth_mechanisms=2) + tester.assert_expected_users(0) + + +@pytest.mark.e2e_replica_set_x509_to_scram_transition +class TestReplicaSetDisableAuthentication(KubernetesTester): + def test_disable_auth(self, replica_set: MongoDB): + replica_set.load() + replica_set["spec"]["security"]["authentication"]["enabled"] = False + replica_set.update() + replica_set.assert_abandons_phase(Phase.Running, timeout=100) + replica_set.assert_reaches_phase(Phase.Running, timeout=900) + + def test_assert_connectivity(self, replica_set: MongoDB, ca_path: str): + replica_set.tester(use_ssl=True, ca_path=ca_path).assert_connectivity() + + def test_ops_manager_state_updated_correctly(self): + tester = AutomationConfigTester(KubernetesTester.get_automation_config()) + tester.assert_authentication_mechanism_disabled("MONGODB-X509") + tester.assert_authentication_mechanism_disabled("SCRAM-SHA-256") + tester.assert_authentication_disabled() + + +@pytest.mark.e2e_replica_set_x509_to_scram_transition +class TestCanEnableScramSha256: + def test_can_enable_scram_sha_256(self, replica_set: MongoDB): + replica_set.load() + replica_set["spec"]["security"]["authentication"]["enabled"] = True + replica_set["spec"]["security"]["authentication"]["modes"] = ["SCRAM"] + replica_set["spec"]["security"]["authentication"]["agents"]["mode"] = "SCRAM" + replica_set.update() + replica_set.assert_abandons_phase(Phase.Running, timeout=100) + replica_set.assert_reaches_phase(Phase.Running, timeout=900) + + def test_assert_connectivity(self, replica_set: MongoDB, ca_path: str): + replica_set.tester(use_ssl=True, ca_path=ca_path).assert_connectivity() + + def test_ops_manager_state_updated_correctly(self): + tester = AutomationConfigTester(KubernetesTester.get_automation_config()) + tester.assert_authentication_mechanism_disabled("MONGODB-X509") + tester.assert_authentication_mechanism_enabled("SCRAM-SHA-256") + tester.assert_expected_users(0) + tester.assert_authentication_enabled() + tester.assert_authoritative_set(True) + + +@pytest.mark.e2e_replica_set_x509_to_scram_transition +class TestCreateScramSha256User(KubernetesTester): + """ + description: | + Creates a SCRAM-SHA-256 user + create: + file: scram-sha-user.yaml + patch: '[{"op":"replace","path":"/spec/mongodbResourceRef/name","value": "replica-set-x509-to-scram-256" }]' + wait_until: in_updated_state + timeout: 150 + """ + + @classmethod + def setup_class(cls): + print(f"creating password for MongoDBUser {USER_NAME} in secret/{PASSWORD_SECRET_NAME} ") + KubernetesTester.create_secret( + KubernetesTester.get_namespace(), + PASSWORD_SECRET_NAME, + { + "password": USER_PASSWORD, + }, + ) + super().setup_class() + + def test_user_can_authenticate_with_correct_password(self, ca_path: str): + tester = ReplicaSetTester(MDB_RESOURCE, 3) + tester.assert_scram_sha_authentication( + password="my-password", + username="mms-user-1", + auth_mechanism="SCRAM-SHA-256", + ssl=True, + ssl_ca_certs=ca_path, + # As of today, user CRs don't have the status/phase fields. So there's no other way + # to verify that they were created other than just spinning and checking. + # See https://jira.mongodb.org/browse/CLOUDP-150729 + # 120 * 5s ~= 600s - the usual timeout we use + attempts=120, + ) + + def test_user_cannot_authenticate_with_incorrect_password(self, ca_path: str): + tester = ReplicaSetTester(MDB_RESOURCE, 3) + tester.assert_scram_sha_authentication_fails( + password="invalid-password", + username="mms-user-1", + auth_mechanism="SCRAM-SHA-256", + ssl=True, + ssl_ca_certs=ca_path, + ) + + +@pytest.mark.e2e_replica_set_x509_to_scram_transition +class TestReplicaSetDeleted(KubernetesTester): + """ + description: | + Deletes the Replica Set + delete: + file: replica-set-x509-to-scram-256.yaml + wait_until: mongo_resource_deleted + timeout: 240 + """ + + def test_noop(self): + pass diff --git a/docker/mongodb-enterprise-tests/tests/authentication/sha1_connectivity_tests.py b/docker/mongodb-enterprise-tests/tests/authentication/sha1_connectivity_tests.py new file mode 100644 index 000000000..476eb8fce --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/sha1_connectivity_tests.py @@ -0,0 +1,153 @@ +import kubernetes +from pytest import fixture + +from kubetester import create_or_update, MongoDB, create_or_update_secret, try_load +from kubetester.automation_config_tester import AutomationConfigTester +from kubetester.kubetester import ( + fixture as yaml_fixture, + KubernetesTester, + run_periodically, +) +from kubetester.mongodb import Phase +from kubetester.mongodb_user import MongoDBUser +from kubetester.mongotester import MongoTester + + +class SHA1ConnectivityTests: + PASSWORD_SECRET_NAME = "mms-user-1-password" + USER_PASSWORD = "my-password" + USER_NAME = "mms-user-1" + + @fixture + def yaml_file(self): + raise Exception("Not implemented, should be defined in a subclass") + + @fixture + def mdb_resource_name(self): + raise Exception("Not implemented, should be defined in a subclass") + + @fixture + def mongo_tester(self, mdb_resource_name: str): + raise Exception("Not implemented, should be defined in a subclass") + + @fixture + def mdb(self, namespace, mdb_resource_name, yaml_file, custom_mdb_version: str): + mdb = MongoDB.from_yaml( + yaml_fixture(yaml_file), + namespace=namespace, + name=mdb_resource_name, + ) + mdb["spec"]["version"] = custom_mdb_version + + try_load(mdb) + return mdb + + def test_create_cluster(self, mdb: MongoDB): + create_or_update(mdb) + mdb.assert_reaches_phase(Phase.Running) + + def test_cluster_connectivity(self, mongo_tester: MongoTester): + mongo_tester.assert_connectivity() + + def test_ops_manager_state_correctly_updated(self): + tester = AutomationConfigTester(KubernetesTester.get_automation_config()) + tester.assert_authentication_mechanism_enabled("MONGODB-CR") + tester.assert_authoritative_set(True) + tester.assert_authentication_enabled(2) + tester.assert_expected_users(0) + + # CreateMongoDBUser + + def test_create_secret(self): + print(f"creating password for MongoDBUser {self.USER_NAME} in secret/{self.PASSWORD_SECRET_NAME} ") + + create_or_update_secret( + KubernetesTester.get_namespace(), + self.PASSWORD_SECRET_NAME, + { + "password": self.USER_PASSWORD, + }, + ) + + def test_create_user(self, namespace: str, mdb_resource_name: str): + mdb = MongoDBUser.from_yaml( + yaml_fixture("scram-sha-user.yaml"), + namespace=namespace, + ) + mdb["spec"]["mongodbResourceRef"]["name"] = mdb_resource_name + + create_or_update(mdb) + mdb.assert_reaches_phase(Phase.Updated, timeout=150) + + # ClusterIsUpdatedWithNewUser + + def test_ops_manager_state_with_users_correctly_updated(self): + expected_roles = { + ("admin", "clusterAdmin"), + ("admin", "userAdminAnyDatabase"), + ("admin", "readWrite"), + ("admin", "userAdminAnyDatabase"), + } + + tester = AutomationConfigTester(KubernetesTester.get_automation_config()) + tester.assert_has_user(self.USER_NAME) + tester.assert_user_has_roles(self.USER_NAME, expected_roles) + tester.assert_expected_users(1) + + def test_user_cannot_authenticate_with_incorrect_password(self, mongo_tester: MongoTester): + mongo_tester.assert_scram_sha_authentication_fails( + password="invalid-password", + username="mms-user-1", + auth_mechanism="SCRAM-SHA-1", + ) + + def test_user_can_authenticate_with_correct_password(self, mongo_tester: MongoTester): + mongo_tester.assert_scram_sha_authentication( + password="my-password", + username="mms-user-1", + auth_mechanism="SCRAM-SHA-1", + attempts=20, + ) + + # CanChangePassword + + def test_update_secret(self, mdb: MongoDB): + print(f"updating password for MongoDBUser {self.USER_NAME} in secret/{self.PASSWORD_SECRET_NAME}") + KubernetesTester.update_secret( + KubernetesTester.get_namespace(), + self.PASSWORD_SECRET_NAME, + {"password": "my-new-password"}, + ) + + def test_user_can_authenticate_with_new_password(self, mongo_tester: MongoTester): + mongo_tester.assert_scram_sha_authentication( + password="my-new-password", + username="mms-user-1", + auth_mechanism="SCRAM-SHA-1", + attempts=20, + ) + + def test_user_cannot_authenticate_with_old_password(self, mongo_tester: MongoTester): + mongo_tester.assert_scram_sha_authentication_fails( + password="my-password", + username="mms-user-1", + auth_mechanism="SCRAM-SHA-1", + ) + + def test_authentication_is_disabled_once_resource_is_deleted(namespace: str, mdb: MongoDB): + mdb.delete() + + def resource_is_deleted() -> bool: + try: + mdb.load() + return False + except kubernetes.client.ApiException as e: + return e.status == 404 + + # wait until the resource is deleted + run_periodically(resource_is_deleted, timeout=300) + + def authentication_was_disabled() -> bool: + return KubernetesTester.get_automation_config()["auth"]["disabled"] + + run_periodically(authentication_was_disabled, timeout=60) diff --git a/docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_ldap.py b/docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_ldap.py new file mode 100644 index 000000000..b12c26523 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_ldap.py @@ -0,0 +1,78 @@ +from typing import List + +from pytest import mark, fixture + +from kubetester.kubetester import fixture as yaml_fixture, KubernetesTester + +from kubetester.mongotester import ShardedClusterTester +from kubetester.mongodb import MongoDB, Phase +from kubetester.ldap import OpenLDAP, LDAPUser + + +@fixture(scope="module") +def sharded_cluster(openldap: OpenLDAP, namespace: str) -> MongoDB: + bind_query_password_secret = "bind-query-password" + resource = MongoDB.from_yaml( + yaml_fixture("ldap/ldap-sharded-cluster.yaml"), namespace=namespace + ) + + KubernetesTester.create_secret( + namespace, bind_query_password_secret, {"password": openldap.admin_password} + ) + + resource["spec"]["security"]["authentication"]["ldap"] = { + "servers": [openldap.servers], + "bindQueryPasswordSecretRef": { + "name": bind_query_password_secret, + }, + } + resource["spec"]["security"]["authentication"]["agents"] = {"mode": "SCRAM"} + resource["spec"]["security"]["authentication"]["modes"] = ["LDAP", "SCRAM"] + + return resource.create() + + +@mark.e2e_sharded_cluster_ldap +def test_sharded_cluster_is_running( + sharded_cluster: MongoDB, ldap_mongodb_users: List[LDAPUser] +): + sharded_cluster.assert_reaches_phase(Phase.Running, timeout=1200) + + +# TODO: Move to a MongoDBUsers (based on KubeObject) for this user creation. +@mark.e2e_sharded_cluster_ldap +class TestAddLDAPUsers(KubernetesTester): + """ + name: Create LDAP Users + create_many: + file: ldap/ldap-sharded-cluster-user.yaml + wait_until: all_users_ready + """ + + def test_users_ready(self): + pass + + @staticmethod + def all_users_ready(): + ac = KubernetesTester.get_automation_config() + automation_config_users = 0 + for user in ac["auth"]["usersWanted"]: + if ( + user["user"] != "mms-backup-agent" + and user["user"] != "mms-monitoring-agent" + ): + automation_config_users += 1 + + return automation_config_users == 1 + + +@mark.e2e_sharded_cluster_ldap +def test_new_mdb_users_are_created( + sharded_cluster: MongoDB, ldap_mongodb_users: List[LDAPUser] +): + tester = ShardedClusterTester(sharded_cluster.name, 1) + + for ldap_user in ldap_mongodb_users: + tester.assert_ldap_authentication( + ldap_user.username, ldap_user.password, attempts=10 + ) diff --git a/docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_scram_sha_1_connectivity.py b/docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_scram_sha_1_connectivity.py new file mode 100644 index 000000000..2f7a68ab1 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_scram_sha_1_connectivity.py @@ -0,0 +1,20 @@ +from pytest import fixture +import pytest + +from kubetester.mongotester import ShardedClusterTester +from tests.authentication.sha1_connectivity_tests import SHA1ConnectivityTests + + +@pytest.mark.e2e_sharded_cluster_scram_sha_1_user_connectivity +class TestShardedClusterSHA1Connectivity(SHA1ConnectivityTests): + @fixture + def yaml_file(self): + return "sharded-cluster-explicit-scram-sha-1.yaml" + + @fixture + def mdb_resource_name(self): + return "my-sharded-cluster-scram-sha-1" + + @fixture + def mongo_tester(self, mdb_resource_name: str): + return ShardedClusterTester(mdb_resource_name, 2) diff --git a/docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_scram_sha_256_connectivity.py b/docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_scram_sha_256_connectivity.py new file mode 100644 index 000000000..4ba056415 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_scram_sha_256_connectivity.py @@ -0,0 +1,127 @@ +import pytest + +from kubetester.kubetester import KubernetesTester +from kubetester.mongotester import ShardedClusterTester +from kubetester.automation_config_tester import AutomationConfigTester + +MDB_RESOURCE = "sharded-cluster-scram-sha-256" +USER_NAME = "mms-user-1" +PASSWORD_SECRET_NAME = "mms-user-1-password" +USER_PASSWORD = "my-password" + + +@pytest.mark.e2e_sharded_cluster_scram_sha_256_user_connectivity +class TestShardedClusterCreation(KubernetesTester): + """ + description: | + Creates a Sharded Cluster and checks everything is created as expected. + create: + file: sharded-cluster-scram-sha-256.yaml + wait_until: in_running_state + """ + + def test_sharded_cluster_connectivity(self): + ShardedClusterTester(MDB_RESOURCE, 2).assert_connectivity() + + def test_ops_manager_state_correctly_updated(self): + tester = AutomationConfigTester(KubernetesTester.get_automation_config()) + tester.assert_authentication_mechanism_enabled("SCRAM-SHA-256") + tester.assert_authentication_enabled() + + +@pytest.mark.e2e_sharded_cluster_scram_sha_256_user_connectivity +class TestCreateMongoDBUser(KubernetesTester): + """ + description: | + Creates a MongoDBUser + create: + file: scram-sha-user.yaml + patch: '[{"op":"replace","path":"/spec/mongodbResourceRef/name","value": "sharded-cluster-scram-sha-256" }]' + wait_until: in_updated_state + timeout: 150 + """ + + @classmethod + def setup_class(cls): + print( + f"creating password for MongoDBUser {USER_NAME} in secret/{PASSWORD_SECRET_NAME} " + ) + KubernetesTester.create_secret( + KubernetesTester.get_namespace(), + PASSWORD_SECRET_NAME, + { + "password": USER_PASSWORD, + }, + ) + super().setup_class() + + def test_create_user(self): + pass + + +@pytest.mark.e2e_sharded_cluster_scram_sha_256_user_connectivity +class TestShardedClusterIsUpdatedWithNewUser(KubernetesTester): + def test_sharded_cluster_connectivity(self): + ShardedClusterTester(MDB_RESOURCE, 2).assert_connectivity() + + def test_ops_manager_state_correctly_updated(self): + expected_roles = { + ("admin", "clusterAdmin"), + ("admin", "userAdminAnyDatabase"), + ("admin", "readWrite"), + ("admin", "userAdminAnyDatabase"), + } + + tester = AutomationConfigTester(KubernetesTester.get_automation_config()) + tester.assert_has_user(USER_NAME) + tester.assert_user_has_roles(USER_NAME, expected_roles) + tester.assert_authentication_mechanism_enabled("SCRAM-SHA-256") + tester.assert_authentication_enabled() + tester.assert_expected_users(1) + + def test_user_cannot_authenticate_with_incorrect_password(self): + tester = ShardedClusterTester(MDB_RESOURCE, 2) + tester.assert_scram_sha_authentication_fails( + password="invalid-password", + username="mms-user-1", + auth_mechanism="SCRAM-SHA-256", + ) + + def test_user_can_authenticate_with_correct_password(self): + tester = ShardedClusterTester(MDB_RESOURCE, 2) + tester.assert_scram_sha_authentication( + password="my-password", + username="mms-user-1", + auth_mechanism="SCRAM-SHA-256", + ) + + +@pytest.mark.e2e_sharded_cluster_scram_sha_256_user_connectivity +class TestCanChangePassword(KubernetesTester): + @classmethod + def setup_env(cls): + print( + f"updating password for MongoDBUser {USER_NAME} in secret/{PASSWORD_SECRET_NAME}" + ) + KubernetesTester.update_secret( + KubernetesTester.get_namespace(), + PASSWORD_SECRET_NAME, + {"password": "my-new-password"}, + ) + + def test_user_can_authenticate_with_new_password(self): + tester = ShardedClusterTester(MDB_RESOURCE, 2) + tester.assert_scram_sha_authentication( + password="my-new-password", + username="mms-user-1", + auth_mechanism="SCRAM-SHA-256", + attempts=20, + ) + + def test_user_cannot_authenticate_with_old_password(self): + tester = ShardedClusterTester(MDB_RESOURCE, 2) + tester.assert_scram_sha_authentication_fails( + password="my-password", + username="mms-user-1", + auth_mechanism="SCRAM-SHA-256", + ) diff --git a/docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_scram_sha_and_x509.py b/docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_scram_sha_and_x509.py new file mode 100644 index 000000000..d525a6a1c --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_scram_sha_and_x509.py @@ -0,0 +1,190 @@ +import pytest + +from kubetester import create_secret +from kubetester.kubetester import KubernetesTester +from kubetester.mongodb_user import MongoDBUser +from kubetester.mongotester import ShardedClusterTester +from kubetester.mongodb import MongoDB, Phase +from kubetester.automation_config_tester import AutomationConfigTester +from kubetester.certs import ( + create_x509_agent_tls_certs, + create_sharded_cluster_certs, + create_x509_user_cert, +) + +import tempfile + +from kubetester.kubetester import fixture as load_fixture + +MDB_RESOURCE = "sharded-cluster-tls-scram-sha-256" +USER_NAME = "mms-user-1" +PASSWORD_SECRET_NAME = "mms-user-1-password" +USER_PASSWORD = "my-password" + + +@pytest.fixture(scope="module") +def server_certs(issuer: str, namespace: str): + create_sharded_cluster_certs( + namespace, + MDB_RESOURCE, + shards=1, + mongos_per_shard=3, + config_servers=3, + mongos=2, + ) + + +@pytest.fixture(scope="module") +def agent_certs(issuer: str, namespace: str) -> str: + return create_x509_agent_tls_certs(issuer, namespace, MDB_RESOURCE) + + +@pytest.fixture(scope="module") +def sharded_cluster(namespace: str, server_certs, agent_certs: str, issuer_ca_configmap: str) -> MongoDB: + res = MongoDB.from_yaml(load_fixture("sharded-cluster-tls-scram-sha-256.yaml"), namespace=namespace) + res["spec"]["security"]["tls"]["ca"] = issuer_ca_configmap + return res.create() + + +@pytest.fixture(scope="module") +def mongodb_user_password_secret(namespace: str) -> str: + create_secret( + namespace=namespace, + name=PASSWORD_SECRET_NAME, + data={ + "password": USER_PASSWORD, + }, + ) + return PASSWORD_SECRET_NAME + + +@pytest.fixture(scope="module") +def scram_user(sharded_cluster: MongoDB, mongodb_user_password_secret: str, namespace: str) -> MongoDBUser: + user = MongoDBUser.from_yaml(load_fixture("scram-sha-user.yaml"), namespace=namespace) + user["spec"]["mongodbResourceRef"]["name"] = sharded_cluster.name + user["spec"]["passwordSecretKeyRef"]["name"] = mongodb_user_password_secret + return user.create() + + +@pytest.fixture(scope="module") +def x509_user(sharded_cluster: MongoDB, namespace: str) -> MongoDBUser: + user = MongoDBUser.from_yaml(load_fixture("test-x509-user.yaml"), namespace=namespace) + user["spec"]["mongodbResourceRef"]["name"] = sharded_cluster.name + return user.create() + + +@pytest.mark.e2e_sharded_cluster_scram_sha_and_x509 +def test_sharded_cluster_running(sharded_cluster: MongoDB): + sharded_cluster.assert_reaches_phase(Phase.Running, timeout=400) + + +@pytest.mark.e2e_sharded_cluster_scram_sha_and_x509 +def test_sharded_cluster_connectivity(sharded_cluster: MongoDB, ca_path: str): + tester = sharded_cluster.tester(use_ssl=True, ca_path=ca_path) + tester.assert_connectivity() + + +@pytest.mark.e2e_sharded_cluster_scram_sha_and_x509 +def test_ops_manager_state_correctly_updated(): + tester = AutomationConfigTester(KubernetesTester.get_automation_config()) + tester.assert_authentication_mechanism_enabled("SCRAM-SHA-256") + tester.assert_authentication_enabled() + + +@pytest.mark.e2e_sharded_cluster_scram_sha_and_x509 +def test_user_reaches_updated_phase(scram_user: MongoDBUser): + scram_user.assert_reaches_phase(Phase.Updated, timeout=150) + + +@pytest.mark.e2e_sharded_cluster_scram_sha_and_x509 +def test_user_can_authenticate_with_correct_password(ca_path: str): + tester = ShardedClusterTester(MDB_RESOURCE, 2) + # As of today, user CRs don't have the status/phase fields. So there's no other way + # to verify that they were created other than just spinning and checking. + # See https://jira.mongodb.org/browse/CLOUDP-150729 + # 120 * 5s ~= 600s - the usual timeout we use + tester.assert_scram_sha_authentication( + password="my-password", + username="mms-user-1", + ssl=True, + auth_mechanism="SCRAM-SHA-256", + ssl_ca_certs=ca_path, + attempts=120, + ) + + +@pytest.mark.e2e_sharded_cluster_scram_sha_and_x509 +def test_user_cannot_authenticate_with_incorrect_password(ca_path: str): + tester = ShardedClusterTester(MDB_RESOURCE, 2) + tester.assert_scram_sha_authentication_fails( + password="invalid-password", + username="mms-user-1", + ssl=True, + auth_mechanism="SCRAM-SHA-256", + ssl_ca_certs=ca_path, + ) + + +@pytest.mark.e2e_sharded_cluster_scram_sha_and_x509 +def test_enable_x509(sharded_cluster: MongoDB): + sharded_cluster.load() + sharded_cluster["spec"]["security"]["authentication"]["modes"].append("X509") + sharded_cluster["spec"]["security"]["authentication"]["agents"] = {"mode": "SCRAM"} + sharded_cluster.update() + sharded_cluster.assert_abandons_phase(Phase.Running, timeout=50) + sharded_cluster.assert_reaches_phase(Phase.Running, timeout=900) + + +@pytest.mark.e2e_sharded_cluster_scram_sha_and_x509 +def test_ops_manager_state_correctly_updated(): + tester = AutomationConfigTester(KubernetesTester.get_automation_config()) + tester.assert_authentication_mechanism_enabled("MONGODB-X509", active_auth_mechanism=False) + tester.assert_authentication_mechanism_enabled("SCRAM-SHA-256") + tester.assert_authentication_enabled(expected_num_deployment_auth_mechanisms=2) + tester.assert_expected_users(1) + + +@pytest.mark.e2e_sharded_cluster_scram_sha_and_x509 +def test_x509_user_exists_in_automation_config(x509_user: MongoDBUser): + ac = KubernetesTester.get_automation_config() + users = ac["auth"]["usersWanted"] + return x509_user["spec"]["username"] in (user["user"] for user in users) + + +@pytest.mark.e2e_sharded_cluster_scram_sha_and_x509 +class TestX509CertCreationAndApproval(KubernetesTester): + def setup(self): + self.cert_file = tempfile.NamedTemporaryFile(delete=False, mode="w") + + def test_create_user_and_authenticate(self, issuer: str, namespace: str, ca_path: str): + create_x509_user_cert(issuer, namespace, path=self.cert_file.name) + tester = ShardedClusterTester(MDB_RESOURCE, 2) + tester.assert_x509_authentication(cert_file_name=self.cert_file.name, ssl_ca_certs=ca_path) + + +@pytest.mark.e2e_sharded_cluster_scram_sha_and_x509 +class TestCanStillAuthAsScramUsers(KubernetesTester): + def test_user_can_authenticate_with_correct_password(self, ca_path: str): + tester = ShardedClusterTester(MDB_RESOURCE, 2) + tester.assert_scram_sha_authentication( + password="my-password", + username="mms-user-1", + ssl=True, + auth_mechanism="SCRAM-SHA-256", + ssl_ca_certs=ca_path, + # As of today, user CRs don't have the status/phase fields. So there's no other way + # to verify that they were created other than just spinning and checking. + # See https://jira.mongodb.org/browse/CLOUDP-150729 + # 120 * 5s ~= 600s - the usual timeout we use + attempts=120, + ) + + def test_user_cannot_authenticate_with_incorrect_password(self, ca_path: str): + tester = ShardedClusterTester(MDB_RESOURCE, 2) + tester.assert_scram_sha_authentication_fails( + password="invalid-password", + username="mms-user-1", + ssl=True, + auth_mechanism="SCRAM-SHA-256", + ssl_ca_certs=ca_path, + ) diff --git a/docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_scram_sha_upgrade.py b/docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_scram_sha_upgrade.py new file mode 100644 index 000000000..d7afe2307 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_scram_sha_upgrade.py @@ -0,0 +1,44 @@ +import pytest + +from kubetester.kubetester import KubernetesTester +from kubetester.mongotester import ShardedClusterTester +from kubetester.automation_config_tester import AutomationConfigTester + +MDB_RESOURCE = "my-sharded-cluster-scram-sha-1" + + +@pytest.mark.e2e_sharded_cluster_scram_sha_1_upgrade +class TestCreateScramSha1ShardedCluster(KubernetesTester): + """ + description: | + Creates a ShardedCluster with SCRAM-SHA-1 authentication + create: + file: sharded-cluster-scram-sha-1.yaml + wait_until: in_running_state + """ + + def test_assert_connectivity(self): + ShardedClusterTester(MDB_RESOURCE, 2).assert_connectivity() + + def test_ops_manager_state_updated_correctly(self): + tester = AutomationConfigTester(KubernetesTester.get_automation_config()) + tester.assert_authentication_mechanism_enabled("SCRAM-SHA-256") + tester.assert_authentication_enabled() + + tester.assert_expected_users(0) + tester.assert_authoritative_set(True) + + +@pytest.mark.e2e_sharded_cluster_scram_sha_1_upgrade +class TestShardedClusterDeleted(KubernetesTester): + """ + description: | + Deletes the Sharded Cluster + delete: + file: sharded-cluster-scram-sha-1.yaml + wait_until: mongo_resource_deleted + timeout: 240 + """ + + def test_noop(self): + pass diff --git a/docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_scram_x509_ic_manual_certs.py b/docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_scram_x509_ic_manual_certs.py new file mode 100644 index 000000000..34400e426 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_scram_x509_ic_manual_certs.py @@ -0,0 +1,84 @@ +from kubetester.certs import create_mongodb_tls_certs, SetProperties +from kubetester.mongodb import MongoDB, Phase + + +from kubetester.kubetester import fixture as _fixture +from pytest import mark, fixture + +MDB_RESOURCE = "sharded-cluster-scram-sha-256" +SUBJECT = {"organizations": ["MDB Tests"], "organizationalUnits": ["Servers"]} +SERVER_SETS = frozenset( + [ + SetProperties(MDB_RESOURCE + "-0", MDB_RESOURCE + "-sh", 3), + SetProperties(MDB_RESOURCE + "-config", MDB_RESOURCE + "-cs", 3), + SetProperties(MDB_RESOURCE + "-mongos", MDB_RESOURCE + "-svc", 2), + ] +) + + +@fixture(scope="module") +def all_certs(issuer, namespace) -> None: + """Generates all required TLS certificates: Servers and Client/Member.""" + spec_server = { + "subject": SUBJECT, + "usages": ["server auth"], + } + spec_client = { + "subject": SUBJECT, + "usages": ["client auth"], + } + + for server_set in SERVER_SETS: + create_mongodb_tls_certs( + issuer, + namespace, + server_set.name, + server_set.name + "-cert", + server_set.replicas, + server_set.service, + spec_server, + ) + create_mongodb_tls_certs( + issuer, + namespace, + server_set.name, + server_set.name + "-clusterfile", + server_set.replicas, + server_set.service, + spec_client, + ) + + +@fixture(scope="module") +def sharded_cluster( + namespace: str, + all_certs, + issuer_ca_configmap: str, +) -> MongoDB: + mdb: MongoDB = MongoDB.from_yaml( + _fixture("sharded-cluster-scram-sha-256-x509-internal-cluster.yaml"), + namespace=namespace, + ) + mdb["spec"]["security"]["tls"]["ca"] = issuer_ca_configmap + return mdb.create() + + +@mark.e2e_sharded_cluster_scram_x509_ic_manual_certs +def test_create_replica_set_with_x509_internal_cluster(sharded_cluster: MongoDB): + sharded_cluster.assert_reaches_phase(Phase.Running, timeout=1200) + + +@mark.e2e_sharded_cluster_scram_x509_ic_manual_certs +def test_create_replica_can_connect(sharded_cluster: MongoDB, ca_path: str): + sharded_cluster.assert_connectivity(ca_path=ca_path) + + +@mark.e2e_sharded_cluster_scram_x509_ic_manual_certs +def test_ops_manager_state_was_updated_correctly(sharded_cluster: MongoDB): + ac_tester = sharded_cluster.get_automation_config_tester() + ac_tester.assert_authentication_enabled(expected_num_deployment_auth_mechanisms=2) + ac_tester.assert_authentication_mechanism_enabled("SCRAM-SHA-256") + ac_tester.assert_internal_cluster_authentication_enabled() + + ac_tester.assert_expected_users(0) + ac_tester.assert_authoritative_set(True) diff --git a/docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_scram_x509_internal_cluster.py b/docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_scram_x509_internal_cluster.py new file mode 100644 index 000000000..66e84e1d3 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_scram_x509_internal_cluster.py @@ -0,0 +1,60 @@ +from kubetester.kubetester import KubernetesTester +from kubetester.automation_config_tester import AutomationConfigTester +from kubetester.omtester import get_sc_cert_names +from pytest import mark, fixture +from kubetester.mongodb import MongoDB, Phase +from kubetester.kubetester import fixture as find_fixture +from kubetester.certs import ( + ISSUER_CA_NAME, + create_x509_mongodb_tls_certs, + create_x509_agent_tls_certs, + create_sharded_cluster_certs, +) + +MDB_RESOURCE_NAME = "sharded-cluster-scram-sha-256" + + +@fixture(scope="module") +def agent_certs(issuer: str, namespace: str) -> str: + return create_x509_agent_tls_certs(issuer, namespace, MDB_RESOURCE_NAME) + + +@fixture(scope="module") +def server_certs(issuer: str, namespace: str): + create_sharded_cluster_certs( + namespace, + MDB_RESOURCE_NAME, + shards=1, + mongos_per_shard=3, + config_servers=3, + mongos=2, + internal_auth=True, + x509_certs=True, + ) + + +@fixture(scope="module") +def sharded_cluster( + namespace: str, server_certs, agent_certs: str, issuer_ca_configmap: str +) -> MongoDB: + resource = MongoDB.from_yaml( + find_fixture("sharded-cluster-scram-sha-256-x509-internal-cluster.yaml"), + namespace=namespace, + ) + resource["spec"]["security"]["tls"]["ca"] = issuer_ca_configmap + yield resource.create() + + +@mark.e2e_sharded_cluster_scram_x509_internal_cluster +class TestReplicaSetScramX509Internal(KubernetesTester): + def test_create_resource(self, sharded_cluster: MongoDB): + sharded_cluster.assert_reaches_phase(Phase.Running, timeout=1200) + + def test_ops_manager_state_was_updated_correctly(self): + ac_tester = AutomationConfigTester(self.get_automation_config()) + ac_tester.assert_authentication_enabled() + ac_tester.assert_authentication_mechanism_enabled("SCRAM-SHA-256") + ac_tester.assert_internal_cluster_authentication_enabled() + + ac_tester.assert_expected_users(0) + ac_tester.assert_authoritative_set(True) diff --git a/docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_x509_internal_cluster_transition.py b/docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_x509_internal_cluster_transition.py new file mode 100644 index 000000000..80bda2cd4 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_x509_internal_cluster_transition.py @@ -0,0 +1,60 @@ +from pytest import mark, fixture + +from kubetester import find_fixture, create_or_update +from kubetester.certs import ( + create_x509_agent_tls_certs, + create_sharded_cluster_certs, +) +from kubetester.mongodb import MongoDB +from kubetester.mongodb import Phase + +MDB_RESOURCE_NAME = "sc-internal-cluster-auth-transition" + + +@fixture(scope="module") +def agent_certs(issuer: str, namespace: str) -> str: + return create_x509_agent_tls_certs(issuer, namespace, MDB_RESOURCE_NAME) + + +@fixture(scope="module") +def server_certs(issuer: str, namespace: str): + create_sharded_cluster_certs( + namespace, + MDB_RESOURCE_NAME, + shards=2, + mongos_per_shard=3, + config_servers=1, + mongos=1, + internal_auth=True, + x509_certs=True, + ) + + +@fixture(scope="module") +def sc(namespace: str, server_certs, agent_certs: str, issuer_ca_configmap: str) -> MongoDB: + resource = MongoDB.from_yaml( + find_fixture("sharded-cluster-x509-internal-cluster-auth-transition.yaml"), + namespace=namespace, + ) + resource["spec"]["security"] = { + "tls": { + "enabled": True, + "ca": issuer_ca_configmap, + }, + "authentication": {"enabled": True, "modes": ["X509"]}, + } + yield create_or_update(resource) + + +@mark.e2e_sharded_cluster_internal_cluster_transition +def test_create_resource(sc: MongoDB): + sc.assert_reaches_phase(Phase.Running, timeout=1200) + + +@mark.e2e_sharded_cluster_internal_cluster_transition +def test_enable_internal_cluster_authentication(sc: MongoDB): + sc.load() + sc["spec"]["security"]["authentication"]["internalCluster"] = "X509" + sc.update() + + sc.assert_reaches_phase(Phase.Running, timeout=2400) diff --git a/docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_x509_to_scram_transition.py b/docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_x509_to_scram_transition.py new file mode 100644 index 000000000..ce898c795 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/authentication/sharded_cluster_x509_to_scram_transition.py @@ -0,0 +1,190 @@ +import pytest + +from kubetester.omtester import get_sc_cert_names +from kubetester.automation_config_tester import AutomationConfigTester +from kubetester.kubetester import KubernetesTester +from kubetester.mongotester import ShardedClusterTester + +from kubetester.mongodb import MongoDB, Phase +from pytest import fixture + +from kubetester.kubetester import fixture as load_fixture +from kubetester.certs import ( + ISSUER_CA_NAME, + create_mongodb_tls_certs, + create_x509_agent_tls_certs, + create_sharded_cluster_certs, +) + +MDB_RESOURCE = "sharded-cluster-x509-to-scram-256" +USER_NAME = "mms-user-1" +PASSWORD_SECRET_NAME = "mms-user-1-password" +USER_PASSWORD = "my-password" + + +@fixture(scope="module") +def agent_certs(issuer: str, namespace: str) -> str: + return create_x509_agent_tls_certs(issuer, namespace, MDB_RESOURCE) + + +@fixture(scope="module") +def server_certs(issuer: str, namespace: str): + create_sharded_cluster_certs( + namespace, + MDB_RESOURCE, + shards=2, + mongos_per_shard=3, + config_servers=2, + mongos=1, + ) + + +@fixture(scope="module") +def sharded_cluster( + namespace: str, server_certs: str, agent_certs: str, issuer_ca_configmap: str +) -> MongoDB: + resource = MongoDB.from_yaml( + load_fixture("sharded-cluster-x509-to-scram-256.yaml"), + namespace=namespace, + ) + resource["spec"]["security"]["tls"]["ca"] = issuer_ca_configmap + yield resource.create() + + +@pytest.mark.e2e_sharded_cluster_x509_to_scram_transition +class TestEnableX509ForShardedCluster(KubernetesTester): + def test_create_resource(self, sharded_cluster: MongoDB): + sharded_cluster.assert_reaches_phase(Phase.Running, timeout=1200) + + def test_ops_manager_state_updated_correctly(self): + tester = AutomationConfigTester(KubernetesTester.get_automation_config()) + tester.assert_authentication_mechanism_enabled("MONGODB-X509") + tester.assert_authentication_enabled() + + +@pytest.mark.e2e_sharded_cluster_x509_to_scram_transition +def test_enable_scram_and_x509(sharded_cluster: MongoDB): + sharded_cluster.load() + sharded_cluster["spec"]["security"]["authentication"]["modes"] = ["X509", "SCRAM"] + sharded_cluster.update() + sharded_cluster.assert_abandons_phase(Phase.Running) + sharded_cluster.assert_reaches_phase(Phase.Running, timeout=900) + + +@pytest.mark.e2e_sharded_cluster_x509_to_scram_transition +def test_x509_is_still_configured(): + tester = AutomationConfigTester(KubernetesTester.get_automation_config()) + tester.assert_authentication_mechanism_enabled("MONGODB-X509") + tester.assert_authentication_mechanism_enabled( + "SCRAM-SHA-256", active_auth_mechanism=False + ) + tester.assert_authentication_enabled(expected_num_deployment_auth_mechanisms=2) + + +@pytest.mark.e2e_sharded_cluster_x509_to_scram_transition +class TestShardedClusterDisableAuthentication(KubernetesTester): + def test_disable_auth(self, sharded_cluster: MongoDB): + sharded_cluster.load() + sharded_cluster["spec"]["security"]["authentication"]["enabled"] = False + sharded_cluster.update() + sharded_cluster.assert_abandons_phase(Phase.Running) + sharded_cluster.assert_reaches_phase(Phase.Running, timeout=1500) + + def test_assert_connectivity(self, ca_path: str): + ShardedClusterTester( + MDB_RESOURCE, 1, ssl=True, ca_path=ca_path + ).assert_connectivity() + + def test_ops_manager_state_updated_correctly(self): + tester = AutomationConfigTester(KubernetesTester.get_automation_config()) + tester.assert_authentication_mechanism_disabled("MONGODB-X509") + tester.assert_authentication_disabled() + + +@pytest.mark.e2e_sharded_cluster_x509_to_scram_transition +class TestCanEnableScramSha256: + def test_can_enable_scram_sha_256(self, sharded_cluster: MongoDB): + sharded_cluster.load() + sharded_cluster["spec"]["security"]["authentication"]["enabled"] = True + sharded_cluster["spec"]["security"]["authentication"]["modes"] = [ + "SCRAM", + ] + sharded_cluster["spec"]["security"]["authentication"]["agents"][ + "mode" + ] = "SCRAM" + sharded_cluster.update() + sharded_cluster.assert_abandons_phase(Phase.Running, timeout=100) + sharded_cluster.assert_reaches_phase(Phase.Running, timeout=1200) + + def test_assert_connectivity(self, ca_path: str): + ShardedClusterTester( + MDB_RESOURCE, 1, ssl=True, ca_path=ca_path + ).assert_connectivity(attempts=25) + + def test_ops_manager_state_updated_correctly(self): + tester = AutomationConfigTester(KubernetesTester.get_automation_config()) + tester.assert_authentication_mechanism_disabled("MONGODB-X509") + tester.assert_authentication_mechanism_enabled("SCRAM-SHA-256") + tester.assert_authentication_enabled() + + +@pytest.mark.e2e_sharded_cluster_x509_to_scram_transition +class TestCreateScramSha256User(KubernetesTester): + """ + description: | + Creates a SCRAM-SHA-256 user + create: + file: scram-sha-user.yaml + patch: '[{"op":"replace","path":"/spec/mongodbResourceRef/name","value": "sharded-cluster-x509-to-scram-256" }]' + wait_until: in_updated_state + timeout: 150 + """ + + @classmethod + def setup_class(cls): + print( + f"creating password for MongoDBUser {USER_NAME} in secret/{PASSWORD_SECRET_NAME} " + ) + KubernetesTester.create_secret( + KubernetesTester.get_namespace(), + PASSWORD_SECRET_NAME, + { + "password": USER_PASSWORD, + }, + ) + super().setup_class() + + def test_user_can_authenticate_with_incorrect_password(self, ca_path: str): + tester = ShardedClusterTester(MDB_RESOURCE, 1) + tester.assert_scram_sha_authentication_fails( + password="invalid-password", + username="mms-user-1", + auth_mechanism="SCRAM-SHA-256", + ssl=True, + ssl_ca_certs=ca_path, + ) + + def test_user_can_authenticate_with_correct_password(self, ca_path: str): + tester = ShardedClusterTester(MDB_RESOURCE, 1) + tester.assert_scram_sha_authentication( + password="my-password", + username="mms-user-1", + auth_mechanism="SCRAM-SHA-256", + ssl=True, + ssl_ca_certs=ca_path, + ) + + +@pytest.mark.e2e_sharded_cluster_x509_to_scram_transition +class TestShardedClusterDeleted(KubernetesTester): + """ + description: | + Deletes the Sharded Cluster + delete: + file: sharded-cluster-x509-to-scram-256.yaml + wait_until: mongo_resource_deleted + timeout: 240 + """ + + def test_noop(self): + pass diff --git a/docker/mongodb-enterprise-tests/tests/clusterwideoperator/__init__.py b/docker/mongodb-enterprise-tests/tests/clusterwideoperator/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/docker/mongodb-enterprise-tests/tests/clusterwideoperator/conftest.py b/docker/mongodb-enterprise-tests/tests/clusterwideoperator/conftest.py new file mode 100644 index 000000000..e69de29bb diff --git a/docker/mongodb-enterprise-tests/tests/clusterwideoperator/om_multiple.py b/docker/mongodb-enterprise-tests/tests/clusterwideoperator/om_multiple.py new file mode 100644 index 000000000..f8e3e217f --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/clusterwideoperator/om_multiple.py @@ -0,0 +1,86 @@ +from kubetester.kubetester import fixture as yaml_fixture, create_testing_namespace +from kubetester.opsmanager import MongoDBOpsManager +from kubetester.mongodb import Phase +from kubetester.operator import Operator +from kubetester.create_or_replace_from_yaml import create_or_replace_from_yaml +from kubetester.helm import helm_template +from kubetester import client, create_secret, read_secret +from pytest import fixture, mark +from typing import Dict + + +def _prepare_om_namespace(ops_manager_namespace: str, operator_installation_config: Dict[str, str]): + """create a new namespace and configures all necessary service accounts there""" + yaml_file = helm_template( + helm_args={ + "registry.imagePullSecrets": operator_installation_config["registry.imagePullSecrets"], + }, + templates="templates/database-roles.yaml", + helm_options=[f"--namespace {ops_manager_namespace}"], + ) + + data = dict( + Username="test-user", + Password="@Sihjifutestpass21nnH", + FirstName="foo", + LastName="bar", + ) + + create_or_replace_from_yaml(client.api_client.ApiClient(), yaml_file) + create_secret(namespace=ops_manager_namespace, name="ops-manager-admin-secret", data=data), + + +def ops_manager(namespace: str, operator_installation_config: Dict[str, str]) -> MongoDBOpsManager: + _prepare_om_namespace(namespace, operator_installation_config) + return MongoDBOpsManager.from_yaml(yaml_fixture("om_ops_manager_basic.yaml"), namespace=namespace) + + +@fixture(scope="module") +def om1(operator_installation_config: Dict[str, str]) -> MongoDBOpsManager: + om = ops_manager("om-1", operator_installation_config) + return om.create() + + +@fixture(scope="module") +def om2(operator_installation_config: Dict[str, str]) -> MongoDBOpsManager: + om = ops_manager("om-2", operator_installation_config) + return om.create() + + +@mark.e2e_om_multiple +def test_install_operator(operator_clusterwide: Operator): + operator_clusterwide.assert_is_running() + + +@mark.e2e_om_multiple +def test_create_namespaces(evergreen_task_id: str): + create_testing_namespace(evergreen_task_id, "om-1") + create_testing_namespace(evergreen_task_id, "om-2") + + +@mark.e2e_om_multiple +def test_multiple_om_created_1(om1: MongoDBOpsManager): + om1.om_status().assert_reaches_phase(Phase.Running, timeout=1100) + + +@mark.e2e_om_multiple +def test_image_pull_secret_om_created_1(namespace: str, operator_installation_config: Dict[str, str]): + """check if imagePullSecrets was cloned in the OM namespace""" + secret_name = operator_installation_config["registry.imagePullSecrets"] + secretDataInOperatorNs = read_secret(namespace, secret_name) + secretDataInOmNs = read_secret("om-1", secret_name) + assert secretDataInOperatorNs == secretDataInOmNs + + +@mark.e2e_om_multiple +def test_multiple_om_created_2(om2: MongoDBOpsManager): + om2.om_status().assert_reaches_phase(Phase.Running, timeout=1100) + + +@mark.e2e_om_multiple +def test_image_pull_secret_om_created_2(namespace: str, operator_installation_config: Dict[str, str]): + """check if imagePullSecrets was cloned in the OM namespace""" + secret_name = operator_installation_config["registry.imagePullSecrets"] + secretDataInOperatorNs = read_secret(namespace, secret_name) + secretDataInOmNs = read_secret("om-2", secret_name) + assert secretDataInOperatorNs == secretDataInOmNs diff --git a/docker/mongodb-enterprise-tests/tests/conftest.py b/docker/mongodb-enterprise-tests/tests/conftest.py new file mode 100644 index 000000000..34381295a --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/conftest.py @@ -0,0 +1,1023 @@ +import os +import subprocess +import tempfile +from typing import Callable, Dict, List, Optional + +import kubernetes +from kubernetes import client +from kubernetes.client import ApiextensionsV1Api +from pytest import fixture + +from kubetester import ( + get_pod_when_ready, + create_or_update_configmap, + is_pod_ready, + read_secret, + update_configmap, +) +from kubetester.awss3client import AwsS3Client +from kubetester.certs import Issuer, Certificate, ClusterIssuer +from kubetester.git import clone_and_checkout +from kubetester.helm import helm_install_from_chart +from kubetester.http import get_retriable_https_session +from kubetester.kubetester import KubernetesTester, running_locally +from kubetester.kubetester import fixture as _fixture +from kubetester.mongodb_multi import MultiClusterClient +from kubetester.operator import Operator +from tests.multicluster import prepare_multi_cluster_namespaces + +try: + kubernetes.config.load_kube_config() +except Exception: + kubernetes.config.load_incluster_config() + + +KUBECONFIG_FILEPATH = "/etc/config/kubeconfig" +MULTI_CLUSTER_CONFIG_DIR = "/etc/multicluster" +# AppDB monitoring is disabled by default for e2e tests. +# If monitoring is needed use monitored_appdb_operator_installation_config / operator_with_monitored_appdb +MONITOR_APPDB_E2E_DEFAULT = "false" +MULTI_CLUSTER_OPERATOR_NAME = "mongodb-enterprise-operator-multi-cluster" +CLUSTER_HOST_MAPPING = { + "us-central1-c_central": "https://35.232.85.244", + "us-east1-b_member-1a": "https://35.243.222.230", + "us-east1-c_member-2a": "https://34.75.94.207", + "us-west1-a_member-3a": "https://35.230.121.15", +} + + +@fixture(scope="module") +def namespace() -> str: + return os.environ["NAMESPACE"] + + +@fixture(scope="module") +def version_id() -> str: + """ + Returns VERSION_ID if it has been defined, or "latest" otherwise. + """ + return os.environ.get("VERSION_ID", "latest") + + +@fixture(scope="module") +def operator_installation_config(namespace: str, version_id: str) -> Dict[str, str]: + """Returns the ConfigMap containing configuration data for the Operator to be created. + Created in the single_e2e.sh""" + config = KubernetesTester.read_configmap(namespace, "operator-installation-config") + config["customEnvVars"] = f"OPS_MANAGER_MONITOR_APPDB={MONITOR_APPDB_E2E_DEFAULT}" + + # if running on evergreen don't use the default image tag + if version_id != "latest": + config["database.version"] = version_id + config["initAppDb.version"] = version_id + config["initDatabase.version"] = version_id + config["initOpsManager.version"] = version_id + + return config + + +@fixture(scope="module") +def monitored_appdb_operator_installation_config(operator_installation_config: Dict[str, str]) -> Dict[str, str]: + """Returns the ConfigMap containing configuration data for the Operator to be created + and for the AppDB to be monitored. + Created in the single_e2e.sh""" + config = operator_installation_config + config["customEnvVars"] = "OPS_MANAGER_MONITOR_APPDB=true" + return config + + +@fixture(scope="module") +def multi_cluster_operator_installation_config( + central_cluster_client: kubernetes.client.ApiClient, namespace: str +) -> Dict[str, str]: + """Returns the ConfigMap containing configuration data for the Operator to be created. + Created in the single_e2e.sh""" + config = KubernetesTester.read_configmap( + namespace, "operator-installation-config", api_client=central_cluster_client + ) + config["customEnvVars"] = f"OPS_MANAGER_MONITOR_APPDB={MONITOR_APPDB_E2E_DEFAULT}" + return config + + +@fixture(scope="module") +def operator_clusterwide( + namespace: str, + operator_installation_config: Dict[str, str], +) -> Operator: + helm_args = operator_installation_config.copy() + helm_args["operator.watchNamespace"] = "*" + return Operator(namespace=namespace, helm_args=helm_args).install() + + +@fixture(scope="module") +def operator_vault_secret_backend( + namespace: str, + monitored_appdb_operator_installation_config: Dict[str, str], +) -> Operator: + helm_args = monitored_appdb_operator_installation_config.copy() + helm_args["operator.vaultSecretBackend.enabled"] = "true" + return Operator(namespace=namespace, helm_args=helm_args).install() + + +@fixture(scope="module") +def operator_vault_secret_backend_tls( + namespace: str, + monitored_appdb_operator_installation_config: Dict[str, str], +) -> Operator: + helm_args = monitored_appdb_operator_installation_config.copy() + helm_args["operator.vaultSecretBackend.enabled"] = "true" + helm_args["operator.vaultSecretBackend.tlsSecretRef"] = "vault-tls" + return Operator(namespace=namespace, helm_args=helm_args).install() + + +@fixture(scope="module") +def evergreen_task_id() -> str: + return os.environ.get("TASK_ID", "") + + +@fixture(scope="module") +def image_type() -> str: + return os.environ["IMAGE_TYPE"] + + +@fixture(scope="module") +def managed_security_context() -> str: + return os.environ["MANAGED_SECURITY_CONTEXT"] + + +@fixture(scope="module") +def aws_s3_client() -> AwsS3Client: + return AwsS3Client("us-east-1") + + +@fixture(scope="session") +def crd_api(): + return ApiextensionsV1Api() + + +@fixture(scope="module") +def cert_manager(namespace: str) -> str: + """Installs cert-manager v1.5.4 using Helm.""" + return install_cert_manager(namespace) + + +@fixture(scope="module") +def multi_cluster_cert_manager( + namespace: str, + central_cluster_client: kubernetes.client.ApiClient, + central_cluster_name: str, + member_cluster_clients: List[MultiClusterClient], +): + install_cert_manager( + namespace, + cluster_client=central_cluster_client, + cluster_name=central_cluster_name, + ) + + for client in member_cluster_clients: + install_cert_manager( + namespace, + cluster_client=client.api_client, + cluster_name=client.cluster_name, + ) + + +@fixture(scope="module") +def issuer(cert_manager: str, namespace: str) -> str: + return create_issuer(namespace=namespace) + + +@fixture(scope="module") +def intermediate_issuer(cert_manager: str, issuer: str, namespace: str) -> str: + """ + This fixture creates an intermediate "Issuer" in the testing namespace + """ + # Create the Certificate for the intermediate CA based on the issuer fixture + intermediate_ca_cert = Certificate(namespace=namespace, name="intermediate-ca-issuer") + intermediate_ca_cert["spec"] = { + "isCA": True, + "commonName": "intermediate-ca-issuer", + "secretName": "intermediate-ca-secret", + "issuerRef": {"name": issuer}, + "dnsNames": ["intermediate-ca.example.com"], + } + intermediate_ca_cert.create().block_until_ready() + + # Create the intermediate issuer + issuer = Issuer(name="intermediate-ca-issuer", namespace=namespace) + issuer["spec"] = {"ca": {"secretName": "intermediate-ca-secret"}} + issuer.create().block_until_ready() + + return "intermediate-ca-issuer" + + +@fixture(scope="module") +def multi_cluster_issuer( + multi_cluster_cert_manager: str, + namespace: str, + central_cluster_client: kubernetes.client.ApiClient, +) -> str: + return create_issuer(namespace, central_cluster_client) + + +@fixture(scope="module") +def multi_cluster_clusterissuer( + multi_cluster_cert_manager: str, + namespace: str, + central_cluster_client: kubernetes.client.ApiClient, +) -> str: + return create_issuer(namespace, central_cluster_client, clusterwide=True) + + +@fixture(scope="module") +def issuer_ca_filepath(): + return _fixture("ca-tls-full-chain.crt") + + +@fixture(scope="module") +def multi_cluster_issuer_ca_configmap( + issuer_ca_filepath: str, + namespace: str, + central_cluster_client: kubernetes.client.ApiClient, +) -> str: + """This is the CA file which verifies the certificates signed by it.""" + ca = open(issuer_ca_filepath).read() + + # The operator expects the CA that validates Ops Manager is contained in + # an entry with a name of "mms-ca.crt" + data = {"ca-pem": ca, "mms-ca.crt": ca} + name = "issuer-ca" + + create_or_update_configmap(namespace, name, data, api_client=central_cluster_client) + + return name + + +@fixture(scope="module") +def issuer_ca_configmap(issuer_ca_filepath: str, namespace: str) -> str: + """This is the CA file which verifies the certificates signed by it.""" + ca = open(issuer_ca_filepath).read() + + # The operator expects the CA that validates Ops Manager is contained in + # an entry with a name of "mms-ca.crt" + data = {"ca-pem": ca, "mms-ca.crt": ca} + + name = "issuer-ca" + create_or_update_configmap(namespace, name, data) + return name + + +@fixture(scope="module") +def ops_manager_issuer_ca_configmap(issuer_ca_filepath: str, namespace: str) -> str: + """ + This is the CA file which verifies the certificates signed by it. + This CA is used to community with Ops Manager. This is needed by the database pods + which talk to OM. + """ + ca = open(issuer_ca_filepath).read() + + # The operator expects the CA that validates Ops Manager is contained in + # an entry with a name of "mms-ca.crt" + data = {"mms-ca.crt": ca} + + name = "ops-manager-issuer-ca" + create_or_update_configmap(namespace, name, data) + return name + + +@fixture(scope="module") +def app_db_issuer_ca_configmap(issuer_ca_filepath: str, namespace: str) -> str: + """ + This is the custom ca used with the AppDB hosts. This can be the same as the one used + for OM but does not need to be the same. + """ + ca = open(issuer_ca_filepath).read() + + name = "app-db-issuer-ca" + create_or_update_configmap(namespace, name, {"ca-pem": ca}) + return name + + +@fixture(scope="module") +def issuer_ca_plus(issuer_ca_filepath: str, namespace: str) -> str: + """Returns the name of a ConfigMap which includes a custom CA and the full + certificate chain for downloads.mongodb.com, fastdl.mongodb.org, + downloads.mongodb.org. This allows for the use of a custom CA while still + allowing the agent to download from MongoDB servers. + + """ + ca = open(issuer_ca_filepath).read() + plus_ca = open(_fixture("downloads.mongodb.com.chained+root.crt")).read() + + # The operator expects the CA that validates Ops Manager is contained in + # an entry with a name of "mms-ca.crt" + data = {"ca-pem": ca + plus_ca, "mms-ca.crt": ca + plus_ca} + + name = "issuer-plus-ca" + create_or_update_configmap(namespace, name, data) + yield name + + +@fixture(scope="module") +def ca_path() -> str: + """Returns a relative path to a file containing the CA. + This is required to test TLS enabled connections to MongoDB like: + + def test_connect(replica_set: MongoDB, ca_path: str) + replica_set.assert_connectivity(ca_path=ca_path) + """ + return _fixture("ca-tls.crt") + + +@fixture(scope="module") +def custom_mdb_version() -> str: + """Returns a CUSTOM_MDB_VERSION for Mongodb to be created/upgraded to for testing. + Defaults to 5.0.14 (simplifies testing locally)""" + return os.getenv("CUSTOM_MDB_VERSION", "5.0.14") + + +@fixture(scope="module") +def custom_appdb_version(custom_mdb_version: str) -> str: + """Returns a CUSTOM_APPDB_VERSION for AppDB to be created/upgraded to for testing, + defaults to custom_mdb_version() (in most cases we need to use the same version for MongoDB as for AppDB) + """ + + return os.getenv("CUSTOM_APPDB_VERSION", f"{custom_mdb_version}-ent") + + +@fixture(scope="module") +def custom_version() -> str: + """Returns a CUSTOM_OM_VERSION for OM. + Defaults to 5.0+ (for development)""" + return os.getenv("CUSTOM_OM_VERSION", "5.0.2") + + +@fixture(scope="module") +def default_operator( + namespace: str, + operator_installation_config: Dict[str, str], +) -> Operator: + """Installs/upgrades a default Operator used by any test not interested in some custom Operator setting. + TODO we use the helm template | kubectl apply -f process so far as Helm install/upgrade needs more refactoring in + the shared environment""" + operator = Operator( + namespace=namespace, + helm_args=operator_installation_config, + ).upgrade() + + # If we're running locally, then immediately after installing the deployment, we scale it to zero. + # Note: There will be a short moment that an operator pod is running interfering with our application + # This way operator in POD is not interfering with locally running one. + if local_operator(): + client.AppsV1Api().patch_namespaced_deployment_scale( + namespace=namespace, + name=operator.name, + body={"spec": {"replicas": 0}}, + ) + + return operator + + +@fixture(scope="module") +def operator_with_monitored_appdb( + namespace: str, + monitored_appdb_operator_installation_config: Dict[str, str], +) -> Operator: + """Installs/upgrades a default Operator used by any test that needs the AppDB monitoring enabled.""" + return Operator( + namespace=namespace, + helm_args=monitored_appdb_operator_installation_config, + ).upgrade() + + +@fixture(scope="module") +def central_cluster_name() -> str: + central_cluster = os.environ.get("CENTRAL_CLUSTER") + if not central_cluster: + raise ValueError("No central cluster specified in environment variable CENTRAL_CLUSTER!") + return central_cluster + + +@fixture(scope="module") +def central_cluster_client( + central_cluster_name: str, cluster_clients: Dict[str, kubernetes.client.ApiClient] +) -> kubernetes.client.ApiClient: + return cluster_clients[central_cluster_name] + + +@fixture(scope="module") +def member_cluster_names() -> List[str]: + member_clusters = os.environ.get("MEMBER_CLUSTERS") + if not member_clusters: + raise ValueError("No member clusters specified in environment variable MEMBER_CLUSTERS!") + return sorted(member_clusters.split()) + + +@fixture(scope="module") +def member_cluster_clients( + cluster_clients: Dict[str, kubernetes.client.ApiClient], + member_cluster_names: List[str], +) -> List[MultiClusterClient]: + member_cluster_clients = [] + for i, member_cluster in enumerate(sorted(member_cluster_names)): + member_cluster_clients.append(MultiClusterClient(cluster_clients[member_cluster], member_cluster, i)) + return member_cluster_clients + + +@fixture(scope="module") +def multi_cluster_operator( + namespace: str, + central_cluster_name: str, + multi_cluster_operator_installation_config: Dict[str, str], + central_cluster_client: client.ApiClient, + member_cluster_clients: List[MultiClusterClient], + member_cluster_names: List[str], +) -> Operator: + os.environ["HELM_KUBECONTEXT"] = central_cluster_name + + # when running with the local operator, this is executed by scripts/dev/prepare_local_e2e_run.sh + if not local_operator(): + run_kube_config_creation_tool(member_cluster_names, namespace, namespace, member_cluster_names) + return _install_multi_cluster_operator( + namespace, + multi_cluster_operator_installation_config, + central_cluster_client, + member_cluster_clients, + { + "operator.name": MULTI_CLUSTER_OPERATOR_NAME, + # override the serviceAccountName for the operator deployment + "operator.createOperatorServiceAccount": "false", + }, + central_cluster_name, + ) + + +@fixture(scope="module") +def multi_cluster_operator_manual_remediation( + namespace: str, + central_cluster_name: str, + multi_cluster_operator_installation_config: Dict[str, str], + central_cluster_client: client.ApiClient, + member_cluster_clients: List[MultiClusterClient], + member_cluster_names: List[str], + cluster_clients, +) -> Operator: + os.environ["HELM_KUBECONTEXT"] = central_cluster_name + run_kube_config_creation_tool(member_cluster_names, namespace, namespace, member_cluster_names) + return _install_multi_cluster_operator( + namespace, + multi_cluster_operator_installation_config, + central_cluster_client, + member_cluster_clients, + { + "operator.name": MULTI_CLUSTER_OPERATOR_NAME, + # override the serviceAccountName for the operator deployment + "operator.createOperatorServiceAccount": "false", + "multiCluster.performFailOver": "false", + }, + central_cluster_name, + ) + + +@fixture(scope="module") +def multi_cluster_operator_clustermode( + namespace: str, + central_cluster_name: str, + multi_cluster_operator_installation_config: Dict[str, str], + central_cluster_client: client.ApiClient, + member_cluster_clients: List[MultiClusterClient], + member_cluster_names: List[str], + cluster_clients: Dict[str, kubernetes.client.ApiClient], +) -> Operator: + os.environ["HELM_KUBECONTEXT"] = central_cluster_name + run_kube_config_creation_tool(member_cluster_names, namespace, namespace, member_cluster_names, True) + return _install_multi_cluster_operator( + namespace, + multi_cluster_operator_installation_config, + central_cluster_client, + member_cluster_clients, + { + "operator.name": MULTI_CLUSTER_OPERATOR_NAME, + # override the serviceAccountName for the operator deployment + "operator.createOperatorServiceAccount": "false", + "operator.watchNamespace": "*", + }, + central_cluster_name, + ) + + +@fixture(scope="module") +def install_multi_cluster_operator_set_members_fn( + namespace: str, + central_cluster_name: str, + multi_cluster_operator_installation_config: Dict[str, str], + central_cluster_client: client.ApiClient, + member_cluster_clients: List[MultiClusterClient], +) -> Callable[[List[str]], Operator]: + def _fn(member_cluster_names: List[str]) -> Operator: + os.environ["HELM_KUBECONTEXT"] = central_cluster_name + mcn = ",".join(member_cluster_names) + return _install_multi_cluster_operator( + namespace, + multi_cluster_operator_installation_config, + central_cluster_client, + member_cluster_clients, + { + "operator.name": MULTI_CLUSTER_OPERATOR_NAME, + # override the serviceAccountName for the operator deployment + "operator.createOperatorServiceAccount": "false", + "multiCluster.clusters": "{" + mcn + "}", + }, + central_cluster_name, + ) + + return _fn + + +def _install_multi_cluster_operator( + namespace: str, + multi_cluster_operator_installation_config: Dict[str, str], + central_cluster_client: client.ApiClient, + member_cluster_clients: List[MultiClusterClient], + helm_opts: Dict[str, str], + central_cluster_name: str, + operator_name: Optional[str] = MULTI_CLUSTER_OPERATOR_NAME, +) -> Operator: + prepare_multi_cluster_namespaces( + namespace, + multi_cluster_operator_installation_config, + member_cluster_clients, + central_cluster_name, + ) + multi_cluster_operator_installation_config.update(helm_opts) + + operator = Operator( + name=operator_name, + namespace=namespace, + helm_args=multi_cluster_operator_installation_config, + api_client=central_cluster_client, + ).upgrade(multi_cluster=True) + + # If we're running locally, then immediately after installing the deployment, we scale it to zero. + # This way operator in POD is not interfering with locally running one. + if local_operator(): + client.AppsV1Api(api_client=central_cluster_client).patch_namespaced_deployment_scale( + namespace=namespace, + name=operator.name, + body={"spec": {"replicas": 0}}, + ) + + return operator + + +@fixture(scope="module") +def official_operator( + namespace: str, + image_type: str, + managed_security_context: str, + operator_installation_config: Dict[str, str], +) -> Operator: + """ + Installs the Operator from the official Helm Chart. + + The version installed is always the latest version published as a Helm Chart. + """ + + helm_options = [] + + # When running in Openshift "managedSecurityContext" will be true. + # When running in kind "managedSecurityContext" will be false, but still use the ubi images. + + helm_args = { + "registry.imagePullSecrets": operator_installation_config["registry.imagePullSecrets"], + "managedSecurityContext": managed_security_context, + } + name = "mongodb-enterprise-operator" + + # Note, that we don't intend to install the official Operator to standalone clusters (kops/openshift) as we want to + # avoid damaged CRDs. But we may need to install the "openshift like" environment to Kind instead if the "ubi" images + # are used for installing the dev Operator + helm_args["operator.operator_image_name"] = name + + temp_dir = tempfile.mkdtemp() + # Values files are now located in `helm-charts` repo. + clone_and_checkout( + "https://github.com/mongodb/helm-charts", + temp_dir, + "main", # main branch of helm-charts. + ) + chart_dir = os.path.join(temp_dir, "charts", "enterprise-operator") + + # When testing the UBI image type we need to assume a few things + + # 1. The testing cluster is Openshift + # 2. The "values.yaml" file is "values-openshift.yaml" + if image_type == "ubi": + helm_options = [ + "--values", + os.path.join(chart_dir, "values-openshift.yaml"), + ] + + # The "official" Operator will be installed, from the Helm Repo ("mongodb/enterprise-operator") + return Operator( + namespace=namespace, + helm_args=helm_args, + helm_chart_path="mongodb/enterprise-operator", + helm_options=helm_options, + name=name, + ).install() + + +def get_headers() -> Dict[str, str]: + """ + Returns an authentication header that can be used when accessing + the Github API. This is to avoid rate limiting when accessing the + API from the Evergreen hosts. + """ + + if github_token := os.getenv("GITHUB_TOKEN_READ"): + return {"Authorization": "token {}".format(github_token)} + + return dict() + + +def fetch_latest_released_operator_version() -> str: + """ + Fetches the currently released operator version from the Github API. + """ + + response = get_retriable_https_session(tls_verify=True).get( + "https://api.github.com/repos/mongodb/mongodb-enterprise-kubernetes/releases/latest", + headers=get_headers(), + ) + response.raise_for_status() + + return response.json()["tag_name"] + + +def _read_multi_cluster_config_value(value: str) -> str: + multi_cluster_config_dir = os.environ.get("MULTI_CLUSTER_CONFIG_DIR", MULTI_CLUSTER_CONFIG_DIR) + filepath = f"{multi_cluster_config_dir}/{value}".rstrip() + if not os.path.isfile(filepath): + raise ValueError(f"{filepath} does not exist!") + with open(filepath, "r") as f: + return f.read().strip() + + +def _get_client_for_cluster( + cluster_name: str, +) -> kubernetes.client.api_client.ApiClient: + token = _read_multi_cluster_config_value(cluster_name) + + if not token: + raise ValueError(f"No token found for cluster {cluster_name}") + + configuration = kubernetes.client.Configuration() + kubernetes.config.load_kube_config( + context=cluster_name, + config_file=os.environ.get("KUBECONFIG", KUBECONFIG_FILEPATH), + client_configuration=configuration, + ) + configuration.host = CLUSTER_HOST_MAPPING.get(cluster_name, configuration.host) + + configuration.verify_ssl = False + configuration.api_key = {"authorization": f"Bearer {token}"} + return kubernetes.client.api_client.ApiClient(configuration=configuration) + + +def install_cert_manager( + namespace: str, + cluster_client: Optional[client.ApiClient] = None, + cluster_name: Optional[str] = None, + name="cert-manager", + version="v1.5.4", +) -> str: + if cluster_name is not None: + # ensure we cert-manager in the member clusters. + os.environ["HELM_KUBECONTEXT"] = cluster_name + + install_required = True + + if running_locally(): + webhook_ready = is_pod_ready( + name, + f"app.kubernetes.io/instance={name},app.kubernetes.io/component=webhook", + api_client=cluster_client, + ) + controller_ready = is_pod_ready( + name, + f"app.kubernetes.io/instance={name},app.kubernetes.io/component=controller", + api_client=cluster_client, + ) + if webhook_ready is not None and controller_ready is not None: + print("Cert manager already installed, skipping helm install") + install_required = False + + if install_required: + helm_install_from_chart( + name, # cert-manager is installed on a specific namespace + name, + f"jetstack/{name}", + version=version, + custom_repo=("jetstack", "https://charts.jetstack.io"), + helm_args={"installCRDs": "true"}, + ) + + # waits until the cert-manager webhook and controller are Ready, otherwise creating + # Certificate Custom Resources will fail. + get_pod_when_ready( + name, + f"app.kubernetes.io/instance={name},app.kubernetes.io/component=webhook", + api_client=cluster_client, + ) + get_pod_when_ready( + name, + f"app.kubernetes.io/instance={name},app.kubernetes.io/component=controller", + api_client=cluster_client, + ) + return name + + +@fixture(scope="module") +def cluster_clients( + namespace: str, member_cluster_names: List[str] +) -> Dict[str, kubernetes.client.api_client.ApiClient]: + member_clusters = [ + _read_multi_cluster_config_value("member_cluster_1"), + _read_multi_cluster_config_value("member_cluster_2"), + ] + + if len(member_cluster_names) == 3: + member_clusters.append(_read_multi_cluster_config_value("member_cluster_3")) + return get_clients_for_clusters(member_clusters) + + +def get_clients_for_clusters( + member_cluster_names: List[str], +) -> Dict[str, kubernetes.client.ApiClient]: + central_cluster = _read_multi_cluster_config_value("central_cluster") + + return {c: _get_client_for_cluster(c) for c in ([central_cluster] + member_cluster_names)} + + +def get_api_servers_from_pod_kubeconfig(kubeconfig: str, cluster_clients: Dict[str, kubernetes.client.ApiClient]): + api_servers = dict() + fd, kubeconfig_tmp_path = tempfile.mkstemp() + with os.fdopen(fd, "w") as fp: + fp.write(kubeconfig) + + for cluster_name, cluster_client in cluster_clients.items(): + configuration = kubernetes.client.Configuration() + kubernetes.config.load_kube_config( + context=cluster_name, + config_file=kubeconfig_tmp_path, + client_configuration=configuration, + ) + api_servers[cluster_name] = configuration.host + + return api_servers + + +def run_kube_config_creation_tool( + member_clusters: List[str], + central_namespace: str, + member_namespace: str, + member_cluster_names: List[str], + cluster_scoped: Optional[bool] = False, + service_account_name: Optional[str] = "mongodb-enterprise-operator-multi-cluster", +): + central_cluster = _read_multi_cluster_config_value("central_cluster") + member_clusters_str = ",".join(member_clusters) + args = [ + os.getenv( + "MULTI_CLUSTER_KUBE_CONFIG_CREATOR_PATH", + "multi-cluster-kube-config-creator", + ), + "multicluster", + "setup", + "--member-clusters", + member_clusters_str, + "--central-cluster", + central_cluster, + "--member-cluster-namespace", + member_namespace, + "--central-cluster-namespace", + central_namespace, + "--service-account", + service_account_name, + ] + + if os.getenv("MULTI_CLUSTER_CREATE_SERVICE_ACCOUNT_TOKEN_SECRETS") == "true": + args.append("--create-service-account-secrets") + + if not local_operator(): + api_servers = get_api_servers_from_test_pod_kubeconfig(member_namespace, member_cluster_names) + + if len(api_servers) > 0: + args.append("--member-clusters-api-servers") + args.append(",".join([api_servers[member_cluster] for member_cluster in member_clusters])) + + if cluster_scoped: + args.append("--cluster-scoped") + + try: + print(f"Running multi-cluster cli setup tool: {' '.join(args)}") + subprocess.check_output(args, stderr=subprocess.PIPE) + print("Finished running multi-cluster cli setup tool") + except subprocess.CalledProcessError as exc: + print("Status: FAIL", exc.returncode, exc.output) + return exc.returncode + + return 0 + + +def get_api_servers_from_kubeconfig_secret( + namespace: str, + secret_name: str, + secret_cluster_client: kubernetes.client.ApiClient, + cluster_clients: Dict[str, kubernetes.client.ApiClient], +): + kubeconfig_secret = read_secret(namespace, secret_name, api_client=secret_cluster_client) + return get_api_servers_from_pod_kubeconfig(kubeconfig_secret["kubeconfig"], cluster_clients) + + +def get_api_servers_from_test_pod_kubeconfig(namespace: str, member_cluster_names: List[str]) -> Dict[str, str]: + test_pod_cluster = os.environ["TEST_POD_CLUSTER"] + cluster_clients = get_clients_for_clusters(member_cluster_names) + + return get_api_servers_from_kubeconfig_secret( + namespace, + "test-pod-kubeconfig", + cluster_clients[test_pod_cluster], + cluster_clients, + ) + + +def run_multi_cluster_recovery_tool( + member_clusters: List[str], + central_namespace: str, + member_namespace: str, + cluster_scoped: Optional[bool] = False, +) -> int: + central_cluster = _read_multi_cluster_config_value("central_cluster") + member_clusters_str = ",".join(member_clusters) + args = [ + os.getenv( + "MULTI_CLUSTER_KUBE_CONFIG_CREATOR_PATH", + "multi-cluster-kube-config-creator", + ), + "multicluster", + "recover", + "--member-clusters", + member_clusters_str, + "--central-cluster", + central_cluster, + "--member-cluster-namespace", + member_namespace, + "--central-cluster-namespace", + central_namespace, + "--operator-name", + MULTI_CLUSTER_OPERATOR_NAME, + "--source-cluster", + member_clusters[0], + ] + if os.getenv("MULTI_CLUSTER_CREATE_SERVICE_ACCOUNT_TOKEN_SECRETS") == "true": + args.append("--create-service-account-secrets") + + if cluster_scoped: + args.extend(["--cluster-scoped", "true"]) + + try: + print(f"Running multi-cluster cli recovery tool: {' '.join(args)}") + subprocess.check_output(args, stderr=subprocess.PIPE) + print("Finished running multi-cluster cli recovery tool") + except subprocess.CalledProcessError as exc: + print("Status: FAIL", exc.returncode, exc.output) + return exc.returncode + return 0 + + +def create_issuer( + namespace: str, + api_client: Optional[client.ApiClient] = None, + clusterwide: bool = False, +): + """ + This fixture creates an "Issuer" in the testing namespace. This requires cert-manager to be installed in the cluster. + The ca-tls.key and ca-tls.crt are the private key and certificates used to generate + certificates. This is based on a Cert-Manager CA Issuer. + More info here: https://cert-manager.io/docs/configuration/ca/ + + Please note, this cert will expire on Dec 8 07:53:14 2023 GMT. + """ + issuer_data = { + "tls.key": open(_fixture("ca-tls.key")).read(), + "tls.crt": open(_fixture("ca-tls.crt")).read(), + } + secret = client.V1Secret( + metadata=client.V1ObjectMeta(name="ca-key-pair"), + string_data=issuer_data, + ) + + try: + if clusterwide: + client.CoreV1Api(api_client=api_client).create_namespaced_secret("cert-manager", secret) + else: + client.CoreV1Api(api_client=api_client).create_namespaced_secret(namespace, secret) + except client.rest.ApiException as e: + if e.status == 409: + print("ca-key-pair already exists") + else: + raise e + + # And then creates the Issuer + if clusterwide: + issuer = ClusterIssuer(name="ca-issuer", namespace="") + else: + issuer = Issuer(name="ca-issuer", namespace=namespace) + + issuer["spec"] = {"ca": {"secretName": "ca-key-pair"}} + issuer.api = kubernetes.client.CustomObjectsApi(api_client=api_client) + + try: + issuer.create().block_until_ready() + except client.rest.ApiException as e: + if e.status == 409: + print("issuer already exists") + else: + raise e + + return "ca-issuer" + + +def local_operator(): + """Checks if the current test run should assume that the operator is running locally, i.e. not in a pod.""" + return os.getenv("LOCAL_OPERATOR", "") == "true" + + +def pod_names(replica_set_name: str, replica_set_members: int) -> list[str]: + """List of pod names for given replica set name.""" + return [f"{replica_set_name}-{i}" for i in range(0, replica_set_members)] + + +def default_external_domain() -> str: + """Default external domain used for testing LoadBalancers on Kind.""" + return "mongodb.interconnected" + + +def external_domain_fqdns( + replica_set_name: str, + replica_set_members: int, + external_domain: str = default_external_domain(), +) -> list[str]: + """Builds list of hostnames for given replica set when connecting to it using external domain.""" + return [f"{pod_name}.{external_domain}" for pod_name in pod_names(replica_set_name, replica_set_members)] + + +def update_coredns_hosts( + host_mappings: list[tuple[str, str]], + cluster_name: Optional[str] = None, + api_client: Optional[kubernetes.client.ApiClient] = None, +): + """Updates kube-system/coredns config map with given host_mappings.""" + + indent = " " * 7 + mapping_string = "\n".join([f"{indent}{host_mapping[0]} {host_mapping[1]}" for host_mapping in host_mappings]) + config_data = {"Corefile": coredns_config("interconnected", mapping_string)} + + if cluster_name is None: + cluster_name = "default cluster" + + print(f"Updating coredns for cluster: {cluster_name}") + update_configmap("kube-system", "coredns", config_data, api_client=api_client) + + +def coredns_config(tld: str, mappings: str): + """Returns coredns config map data with mappings inserted.""" + return f""" +.:53 {{ + errors + health {{ + lameduck 5s + }} + ready + kubernetes cluster.local in-addr.arpa ip6.arpa {{ + pods insecure + fallthrough in-addr.arpa ip6.arpa + ttl 30 + }} + prometheus :9153 + forward . /etc/resolv.conf {{ + max_concurrent 1000 + }} + cache 30 + loop + reload + loadbalance + debug + hosts /etc/coredns/customdomains.db {tld} {{ +{mappings} + fallthrough + }} +}} +""" diff --git a/docker/mongodb-enterprise-tests/tests/docs/MINIO.md b/docker/mongodb-enterprise-tests/tests/docs/MINIO.md new file mode 100644 index 000000000..df0906b2f --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/docs/MINIO.md @@ -0,0 +1,15 @@ +This document contains instructions on how to manually run the `om_ops_manager_backup_restore_minio.py` test. The +purpose of this test is to verify that it is possible to use s3 Oplog/Blockstores using a custom CA, +see [CLOUDP-97373](https://jira.mongodb.org/browse/CLOUDP-97373) for more details. + +## Setting up the test environment. + +1. Deploy the Minio operator. This can be done following the + instructions [here](https://docs.min.io/minio/k8s/deployment/deploy-minio-operator.html) +2. Open the Minio **operator dashboard**. (`kubectl minio proxy` in a shell) +3. Login with the JWT token given and create a new tenant group, call it `tenant-0`. Take a note of the access key and + secret key given. +4. Log into the **tenant dashboard** (different from operator dashboard) you will find the credentials in a secret called *-user-0 in the tenant-0 namespace. Create two buckets "oplog-s3-bucket" and "s3-store-bucket". (Ops Manager assumes these buckets exist.) +5. In the Mino Operator UI, add the tls.crt and tls.key from this directory as shown in the image. (this cert and key have been signed using the custom CA fixture.) ![img.png](img.png) +6. Update the fixtures in the test to return all the correct values. You should only need to change the AWS keys. +7. Run the test locally with `make e2e test=e2e_om_ops_manager_backup_restore_minio`. diff --git a/docker/mongodb-enterprise-tests/tests/docs/img.png b/docker/mongodb-enterprise-tests/tests/docs/img.png new file mode 100644 index 0000000000000000000000000000000000000000..25adac04892076341a1346293d42da2a2919bf82 GIT binary patch literal 150083 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%0Dn2NQw~?MpjU!MfNWh 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�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

Eg`Fq602tm>7LT(TtnP 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 B377x? literal 0 HcmV?d00001 diff --git a/docker/mongodb-enterprise-tests/tests/docs/tls.crt b/docker/mongodb-enterprise-tests/tests/docs/tls.crt new file mode 100644 index 000000000..e421c8042 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/docs/tls.crt @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFWjCCA0KgAwIBAgIQWFr56L7RXf693A7FRTeR6TANBgkqhkiG9w0BAQsFADBx +MQswCQYDVQQGEwJVUzERMA8GA1UECAwITmV3IFlvcmsxETAPBgNVBAcMCE5ldyBZ +b3JrMRAwDgYDVQQKDAdNb25nb0RCMRAwDgYDVQQLDAdtb25nb2RiMRgwFgYDVQQD +DA93d3cubW9uZ29kYi5jb20wHhcNMjExMjE2MTUyNDMyWhcNMjExMjI2MTUyNDMy +WjAAMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8HFJAK+dvQEfBBZL +x4OSFNILpq1gP8IILAiXgFzRUzyLm0RH18mflZatiGrI/PuGxnMxQWTv5xyvC36l +9tGYB/rh+JFubaZ8aPKC96JpL9T99wvOjP+t7N7Z913ItK+4M6Kcx4WtXYGZWpUh +xD0KuA5gSEhQL2xZpsMWPgMixfjr6JvMs1c2qpkCoRLhofoQgq4Fn+qLKs99zqI1 +tR/OKJ7sgmQZQBrndkECNNM4Q4PsF+IC3CBFvY2uVffqGOqf+ZDfoxApYIFZLsj2 +vbuslKXSl2mt3R3rl8rgPCEn6b4opMDiS+FwcqUe/HPOOvvbFCEMdzuNpXuvT+2G +s5kGZwIDAQABo4IBXTCCAVkwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC +MAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUznXlURtSHk39OXcZhS5qfJ93gE4w +ggEHBgNVHREBAf8EgfwwgfmCK29tLWJhY2t1cC1zdmMuY2hhdHRvbjkwMTEuc3Zj +LmNsdXN0ZXIubG9jYWyCPnRlbmFudC0wLXBvb2wtMC17MC4uLjN9LnRlbmFudC0w +LWhsLnRlbmFudC0wLnN2Yy5jbHVzdGVyLmxvY2FsgiBtaW5pby50ZW5hbnQtMC5z +dmMuY2x1c3Rlci5sb2NhbIIObWluaW8udGVuYW50LTCCEm1pbmlvLnRlbmFudC0w +LnN2Y4InLnRlbmFudC0wLWhsLnRlbmFudC0wLnN2Yy5jbHVzdGVyLmxvY2Fsghsu +dGVuYW50LTAuc3ZjLmNsdXN0ZXIubG9jYWwwDQYJKoZIhvcNAQELBQADggIBAGaM +gYk3Cwlr+lx4ZcAI1eG+zBFzw2ZtHzJWR4BBw2Oc7fCM3W2Oa56GkoPv6x1BQJap +ZLqQhgGwAX+H3Cxt9Z68T2n3efmB355SwsYsMBab2aDifr8WqFouRLcAIsyY6mgR +MnSjEl/+d0CK9sp7L6w/9umflVGU1hdrzMCmqQNZY3SUoiY/qIYbDgCOsQWQXF0l +47jVoEBVv5ML6gFaQj6biCAwhq7aoOPa5wrDBCWKds4mF9c16ctluYQHhvlahfIb +5jHqjbCmSzd2tHU96Km+Vb0PGkTbZofORZOkBXUzkcuvJv2qbS/wuFBI7Z+d3imH +xLt/xG5g+vBCz2rheY6KqeG7Dde4NCx8aUl73MQKHGDaMYL6tOU8KY5sNdKt0HMD +3EKKsCLkJ6c7e3EorBv76A/LETnJGi6ppVtMyWoeF/NOoDiB35iRyweUY9/+OGOA +PnKtxFcfux466eJRysNhPH15TVomX7MNYd8OF0RzPMzjDpg6R8ZJbcB9BUoY78P6 +m0EUXxemzEczU/05Q2kzHooL6cckKzH/b//trOvIlYMstZYhcMPRzAQzu911m3vc +HM4inwxOtLve9whdw+LmUWq3RcINkerYMvREIWR7DZlAEHyCMbUbUoywA86j7IuE +gJGPja0Q9wStZ2e1je93N8BmFcu28nfYRfWJj551 +-----END CERTIFICATE----- diff --git a/docker/mongodb-enterprise-tests/tests/docs/tls.key b/docker/mongodb-enterprise-tests/tests/docs/tls.key new file mode 100644 index 000000000..651746a18 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/docs/tls.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA8HFJAK+dvQEfBBZLx4OSFNILpq1gP8IILAiXgFzRUzyLm0RH +18mflZatiGrI/PuGxnMxQWTv5xyvC36l9tGYB/rh+JFubaZ8aPKC96JpL9T99wvO +jP+t7N7Z913ItK+4M6Kcx4WtXYGZWpUhxD0KuA5gSEhQL2xZpsMWPgMixfjr6JvM +s1c2qpkCoRLhofoQgq4Fn+qLKs99zqI1tR/OKJ7sgmQZQBrndkECNNM4Q4PsF+IC +3CBFvY2uVffqGOqf+ZDfoxApYIFZLsj2vbuslKXSl2mt3R3rl8rgPCEn6b4opMDi +S+FwcqUe/HPOOvvbFCEMdzuNpXuvT+2Gs5kGZwIDAQABAoIBAGVBX9vxGP1yTmx7 +Mzh3GPq5pfxwQPs4rBZXG+4LqH9kHOqrK5IdL55gUP4E8lVPW2eRNSnz5u+t7a1q +jVvO0jZyGd2C6T02Amhz0GGWvLNPABCcoURRnB4Hj0UT8qTc5zafgWSoz+Rz4m/6 +I7kvd6chLrzh7xq5h1uqBmDhEzDJHQsVyuIPdjr7dzdplZ1W5/7JyWA6OO7CbKeS +gtfqyClm8kXjhY+b/AA2Ogty03ZfZUhRfl2eOxaq7t1Ph4529V+VbdQfU4T4brqw +let5FeHbofKOpyWvJYfa0IU0PjtEsNhFgDQBUBgH5eGzTnlPtFZt6QSh8N/pyJHf +2ErIUIECgYEA8Qn6ChJ7Ri9GmJzsycwf0ITJJCZ2zm4y9KmdRxukxMWIAZbHLomv +JigBOXR9H5TplcMKOPWet5RpDP86dxj/2Qlf7+V/sOPFaqYSk4XZxiG5o4vZW3/w +NF3OpHcM8KK84pH7HmdkN9owOR5nCIN/7JkqQVYRS8NgssV9ukpMdUECgYEA/13U +v8N2rIO6gRFEOGct6eXHfmtKtZg6lvvmB+Pwq5upN4xmWl9KCqM0As04sXqRSY+6 +JX801FdyeeozkTOPNXsiOK3xT/C+5xzCSrra8KvXwE9kMNHmt7C2BbE9jb2dMnlg +iPy2ZUOcpSUxWERcnFyAFpUTF1sCDuaKYY8JSacCgYEAuHkHOSAt4mgaIoCvJD4p +9x85BYa+lHx4WRFawmogr0vyLC0mIbLULmKdlUhW3o3MO4b60t8AasWVpJHNQAsM +/CEVoHdHQ6z+kQGq4+aj5eQ3vDgy0LlYr+s/VFWcvKn/33MT+o/sfmZpU7214ykp +BX2vfjONpytPXWKSN7nXTEECgYBtztZOA2oDcr1/BIK2Uj/fBQyMouxEPAptpDHd +ELoLwOq51SiqEbGP82/JCKApSRAydphPyWxZJqU2IWw9MtOQ5rrnbnyGqHoefTJa +2hCNTwd+TWVCzO+N63HJ7tYOHgv7iU/md+yijLlOFjkqwHKmVexKSZ4k++Bdseqt +WslenwKBgQCQNucIwYZ/IxkN8vsk5huVWJV8fXhfOG9DaytXMVPvwl9C4874KccL +fbMGPMCKepPcREpT8lDW3iDYKlO0c0zYrifM2gVszcCFjXyiX3IU4XHrbnl3Bm7z +hwee7W7wUG6f4h061rom5ks5+4+H6ukpD26wwb0B7BuVfwjjBxXIWg== +-----END RSA PRIVATE KEY----- diff --git a/docker/mongodb-enterprise-tests/tests/mixed/__init__.py b/docker/mongodb-enterprise-tests/tests/mixed/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/docker/mongodb-enterprise-tests/tests/mixed/all_mongodb_resources_parallel_test.py b/docker/mongodb-enterprise-tests/tests/mixed/all_mongodb_resources_parallel_test.py new file mode 100644 index 000000000..e2a250884 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/mixed/all_mongodb_resources_parallel_test.py @@ -0,0 +1,88 @@ +import threading +import time + +import pytest + +from kubetester.kubetester import KubernetesTester, fixture, run_periodically + +mdb_resources = { + "my-standalone": fixture("standalone.yaml"), + "sh001-single": fixture("sharded-cluster-single.yaml"), + "my-replica-set-single": fixture("replica-set-single.yaml"), +} + + +@pytest.mark.e2e_all_mongodb_resources_parallel +class TestRaceConditions(KubernetesTester): + """ + name: Test for no race conditions during creation of three mongodb resources in parallel. + description: | + Makes sure no duplicated organizations/groups are created, while 3 mongodb resources + are created in parallel. Also the automation config doesn't miss entries. + """ + + random_storage_name = None + + @classmethod + def setup_env(cls): + threads = [] + for filename in mdb_resources.values(): + args = (cls.get_namespace(), filename) + threads.append( + threading.Thread(target=cls.create_custom_resource_from_file, args=args) + ) + + [t.start() for t in threads] + [t.join() for t in threads] + + print("Waiting until any of the resources gets to 'Running' state..") + run_periodically(TestRaceConditions.any_resource_created, timeout=360) + + def test_one_resource_created_only(self): + mongodbs = [ + KubernetesTester.get_namespaced_custom_object( + KubernetesTester.get_namespace(), resource, "MongoDB" + ) + for resource in mdb_resources.keys() + ] + assert len([m for m in mongodbs if m["status"]["phase"] == "Running"]) == 1 + + # No duplicated organizations were created and automation config is consistent + organizations = KubernetesTester.find_organizations( + KubernetesTester.get_om_group_name() + ) + assert len(organizations) == 1 + groups = KubernetesTester.find_groups_in_organization( + organizations[0], KubernetesTester.get_om_group_name() + ) + assert len(groups) == 1 + + config = KubernetesTester.get_automation_config() + + # making sure that only one single mdb resource was created + replica_set_created = ( + len(config["replicaSets"]) == 1 and len(config["processes"]) == 1 + ) + sharded_cluster_created = ( + len(config["replicaSets"]) == 2 and len(config["processes"]) == 3 + ) + standalone_created = ( + len(config["replicaSets"]) == 0 and len(config["processes"]) == 1 + ) + + assert replica_set_created + sharded_cluster_created + standalone_created == 1 + + @staticmethod + def any_resource_created(): + namespace = KubernetesTester.get_namespace() + results = [ + KubernetesTester.check_phase(namespace, "MongoDB", resource, "Running") + for resource in mdb_resources.keys() + ] + + print( + "Standalone ready: {}, sharded cluster ready: {}, replica set ready: {}".format( + *results + ) + ) + return any(results) diff --git a/docker/mongodb-enterprise-tests/tests/mixed/conftest.py b/docker/mongodb-enterprise-tests/tests/mixed/conftest.py new file mode 100644 index 000000000..cb0664057 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/mixed/conftest.py @@ -0,0 +1,4 @@ +def pytest_runtest_setup(item): + """This allows to automatically install the default Operator before running any test""" + if "default_operator" not in item.fixturenames: + item.fixturenames.insert(0, "default_operator") diff --git a/docker/mongodb-enterprise-tests/tests/mixed/crd_validation.py b/docker/mongodb-enterprise-tests/tests/mixed/crd_validation.py new file mode 100644 index 000000000..90aa9ea34 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/mixed/crd_validation.py @@ -0,0 +1,42 @@ +""" +Checks that the CRD conform to Structural Schema: +https://kubernetes.io/docs/tasks/access-kubernetes-api/custom-resources/custom-resource-definitions/#specifying-a-structural-schema +""" + +from pytest import mark + +from kubernetes.client import ApiextensionsV1Api, V1CustomResourceDefinition + + +def crd_has_expected_conditions(resource: V1CustomResourceDefinition) -> bool: + for condition in resource.status.conditions: + if condition.type == "NonStructuralSchema": + return False + + return True + + +@mark.e2e_crd_validation +def test_mongodb_crd_is_valid(crd_api: ApiextensionsV1Api): + resource = crd_api.read_custom_resource_definition("mongodb.mongodb.com") + assert crd_has_expected_conditions(resource) + + +@mark.e2e_crd_validation +def test_mongodb_users_crd_is_valid(crd_api: ApiextensionsV1Api): + resource = crd_api.read_custom_resource_definition("mongodbusers.mongodb.com") + assert crd_has_expected_conditions(resource) + + +@mark.e2e_crd_validation +def test_opsmanagers_crd_is_valid(crd_api: ApiextensionsV1Api): + resource = crd_api.read_custom_resource_definition("opsmanagers.mongodb.com") + assert crd_has_expected_conditions(resource) + + +@mark.e2e_crd_validation +def test_mongodbmulti_crd_is_valid(crd_api: ApiextensionsV1Api): + resource = crd_api.read_custom_resource_definition( + "mongodbmulticluster.mongodb.com" + ) + assert crd_has_expected_conditions(resource) diff --git a/docker/mongodb-enterprise-tests/tests/mixed/failures_on_multi_clusters.py b/docker/mongodb-enterprise-tests/tests/mixed/failures_on_multi_clusters.py new file mode 100644 index 000000000..7e4adfd04 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/mixed/failures_on_multi_clusters.py @@ -0,0 +1,142 @@ +from kubetester.kubetester import KubernetesTester, fixture as yaml_fixture +from kubetester.mongodb import MongoDB, Phase +from pytest import mark, fixture + + +@fixture(scope="class") +def replica_set(namespace, custom_mdb_version: str): + resource = MongoDB.from_yaml(yaml_fixture("replica-set.yaml"), namespace=namespace) + resource.set_version(custom_mdb_version) + yield resource.create() + + resource.delete() + + +@fixture(scope="class") +def replica_set_single(namespace, custom_mdb_version: str): + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-single.yaml"), namespace=namespace + ) + resource.set_version(custom_mdb_version) + yield resource.create() + + resource.delete() + + +@fixture(scope="class") +def sharded_cluster(namespace, custom_mdb_version: str): + resource = MongoDB.from_yaml( + yaml_fixture("sharded-cluster.yaml"), namespace=namespace + ) + resource.set_version(custom_mdb_version) + yield resource.create() + + resource.delete() + + +@fixture(scope="class") +def sharded_cluster_single(namespace, custom_mdb_version: str): + resource = MongoDB.from_yaml( + yaml_fixture("sharded-cluster-single.yaml"), namespace=namespace + ) + resource.set_version(custom_mdb_version) + yield resource.create() + + resource.delete() + + +@mark.e2e_multiple_cluster_failures +class TestNoTwoReplicaSetsCanBeCreatedOnTheSameProject: + def test_replica_set_get_to_running_state(self, replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running) + + assert "warnings" not in replica_set["status"] + + def test_no_warnings_when_scaling(self, replica_set: MongoDB): + replica_set["spec"]["members"] = 5 + replica_set.update() + + replica_set.assert_abandons_phase(Phase.Running) + replica_set.assert_reaches_phase(Phase.Running, timeout=500) + assert "warnings" not in replica_set["status"] + + def test_second_mdb_resource_fails(self, replica_set_single: MongoDB): + replica_set_single.assert_reaches_phase(Phase.Pending) + + assert ( + replica_set_single["status"]["message"] + == "Cannot have more than 1 MongoDB Cluster per project (see https://docs.mongodb.com/kubernetes-operator/stable/tutorial/migrate-to-single-resource/)" + ) + + assert "warnings" not in replica_set_single["status"] + + # pylint: disable=unused-argument + def test_automation_config_is_correct(self, replica_set, replica_set_single): + config = KubernetesTester.get_automation_config() + + assert len(config["processes"]) == 5 + for process in config["processes"]: + assert not process["name"].startswith("my-replica-set-single") + + assert len(config["sharding"]) == 0 + assert len(config["replicaSets"]) == 1 + + +@mark.e2e_multiple_cluster_failures +class TestNoTwoClustersCanBeCreatedOnTheSameProject: + def test_sharded_cluster_reaches_running_phase(self, sharded_cluster: MongoDB): + # Unfortunately, Sharded cluster takes a long time to even start. + sharded_cluster.assert_reaches_phase(Phase.Running, timeout=600) + + assert "warnings" not in sharded_cluster["status"] + + def test_second_mdb_sharded_cluster_fails(self, sharded_cluster_single: MongoDB): + sharded_cluster_single.assert_reaches_phase(Phase.Pending) + + assert "warnings" not in sharded_cluster_single["status"] + + # pylint: disable=unused-argument + def test_sharded_cluster_automation_config_is_correct( + self, sharded_cluster, sharded_cluster_single + ): + config = KubernetesTester.get_automation_config() + + for process in config["processes"]: + assert not process["name"].startswith("sh001-single") + + # Creating a Sharded Cluster will result in 1 entry added to `sharding` and ... + assert len(config["sharding"]) == 1 + # ... and also 2 entries into `replicaSets` (1 shard and 1 config replica set, in this case) + assert len(config["replicaSets"]) == 2 + + +@mark.e2e_multiple_cluster_failures +class TestNoTwoDifferentTypeOfResourceCanBeCreatedOnTheSameProject: + def test_multiple_test_different_type_fails(self, replica_set_single: MongoDB): + replica_set_single.assert_reaches_phase(Phase.Running) + + def test_adding_sharded_cluster_fails(self, sharded_cluster_single: MongoDB): + sharded_cluster_single.assert_reaches_phase(Phase.Pending) + + status = sharded_cluster_single["status"] + assert status["phase"] == "Pending" + assert ( + status["message"] + == "Cannot have more than 1 MongoDB Cluster per project (see https://docs.mongodb.com/kubernetes-operator/stable/tutorial/migrate-to-single-resource/)" + ) + + assert "warnings" not in status + + # pylint: disable=unused-argument + def test_automation_config_contains_one_cluster( + self, replica_set_single, sharded_cluster_single + ): + config = KubernetesTester.get_automation_config() + + assert len(config["processes"]) == 1 + + for process in config["processes"]: + assert not process["name"].startswith("sh001-single") + + assert len(config["sharding"]) == 0 + assert len(config["replicaSets"]) == 1 diff --git a/docker/mongodb-enterprise-tests/tests/mixed/fixtures/sample-mdb-object-to-test.yaml b/docker/mongodb-enterprise-tests/tests/mixed/fixtures/sample-mdb-object-to-test.yaml new file mode 100644 index 000000000..ace4e7ca0 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/mixed/fixtures/sample-mdb-object-to-test.yaml @@ -0,0 +1,12 @@ +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: my-mdb-object-to-test +spec: + version: 4.0.15 + type: Standalone + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + persistent: false diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/__init__.py b/docker/mongodb-enterprise-tests/tests/multicluster/__init__.py new file mode 100644 index 000000000..5499e39d6 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/__init__.py @@ -0,0 +1,29 @@ +import os +from typing import Dict, List + +from kubetester.create_or_replace_from_yaml import create_or_replace_from_yaml +from kubetester.helm import helm_template +from kubetester.mongodb_multi import MultiClusterClient + + +def prepare_multi_cluster_namespaces( + namespace: str, + multi_cluster_operator_installation_config: Dict[str, str], + member_cluster_clients: List[MultiClusterClient], + central_cluster_name: str, + skip_central_cluster: bool = True, +): + """create a new namespace and configures all necessary service accounts there""" + + helm_args = multi_cluster_operator_installation_config + yaml_file = helm_template( + helm_args=helm_args, + templates="templates/database-roles.yaml", + helm_options=[f"--namespace {namespace}"], + ) + # create database roles in member clusters. + for mcc in member_cluster_clients: + if skip_central_cluster and mcc.cluster_name == central_cluster_name: + continue + create_or_replace_from_yaml(mcc.api_client, yaml_file) + os.remove(yaml_file) diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/conftest.py b/docker/mongodb-enterprise-tests/tests/multicluster/conftest.py new file mode 100644 index 000000000..a474aab23 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/conftest.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +import re +import urllib +from typing import Generator, List, Dict +from urllib import parse + +import kubernetes +from kubeobject import CustomObject +from kubernetes import client +from kubernetes.client import V1ObjectMeta +from pytest import fixture + +from kubetester import create_or_update_namespace +from kubetester.certs import generate_cert +from kubetester.ldap import ( + OpenLDAP, + LDAPUser, + create_user, + ensure_organizational_unit, + ensure_group, + add_user_to_group, + ldap_initialize, +) +from kubetester.mongodb_multi import MultiClusterClient +from tests.authentication.conftest import ( + openldap_install, + LDAP_NAME, + LDAP_PASSWORD, + AUTOMATION_AGENT_NAME, + ldap_host, +) +from tests.conftest import ( + get_api_servers_from_test_pod_kubeconfig, + get_clients_for_clusters, + create_issuer, +) + +import ipaddress + + +@fixture(scope="module") +def multi_cluster_ldap_issuer( + cert_manager: str, + member_cluster_clients: List[MultiClusterClient], +): + member_cluster_one = member_cluster_clients[0] + # create openldap namespace if it doesn't exist + create_or_update_namespace( + "openldap", + {"istio-injection": "enabled"}, + api_client=member_cluster_one.api_client, + ) + + return create_issuer("openldap", member_cluster_one.api_client) + + +@fixture(scope="module") +def multicluster_openldap_cert( + member_cluster_clients: List[MultiClusterClient], multi_cluster_ldap_issuer: str +) -> str: + """Returns a new secret to be used to enable TLS on LDAP.""" + + member_cluster_one = member_cluster_clients[0] + + host = ldap_host("openldap", LDAP_NAME) + return generate_cert( + "openldap", + "openldap", + host, + multi_cluster_ldap_issuer, + api_client=member_cluster_one.api_client, + ) + + +@fixture(scope="module") +def multicluster_openldap_tls( + member_cluster_clients: List[MultiClusterClient], + multicluster_openldap_cert: str, + ca_path: str, +) -> Generator[OpenLDAP, None, None]: + member_cluster_one = member_cluster_clients[0] + helm_args = { + "tls.enabled": "true", + "tls.secret": multicluster_openldap_cert, + # Do not require client certificates + "env.LDAP_TLS_VERIFY_CLIENT": "never", + } + server = openldap_install( + "openldap", + LDAP_NAME, + helm_args=helm_args, + cluster_client=member_cluster_one.api_client, + cluster_name=member_cluster_one.cluster_name, + tls=True, + ) + # When creating a new OpenLDAP container with TLS enabled, the container is ready, but the server is not accepting + # requests, as it's generating DH parameters for the TLS config. Only using retries!=0 for ldap_initialize when creating + # the OpenLDAP server. + ldap_initialize(server, ca_path=ca_path, retries=10) + return server + + +@fixture(scope="module") +def ldap_mongodb_user(multicluster_openldap_tls: OpenLDAP, ca_path: str) -> LDAPUser: + user = LDAPUser("mdb0", LDAP_PASSWORD) + + ensure_organizational_unit(multicluster_openldap_tls, "groups", ca_path=ca_path) + create_user(multicluster_openldap_tls, user, ou="groups", ca_path=ca_path) + + ensure_group(multicluster_openldap_tls, cn="users", ou="groups", ca_path=ca_path) + add_user_to_group( + multicluster_openldap_tls, + user="mdb0", + group_cn="users", + ou="groups", + ca_path=ca_path, + ) + + return user + + +@fixture(scope="module") +def ldap_mongodb_users( + multicluster_openldap_tls: OpenLDAP, ca_path: str +) -> List[LDAPUser]: + user_list = [LDAPUser("mdb0", LDAP_PASSWORD)] + for user in user_list: + create_user(multicluster_openldap_tls, user, ou="groups", ca_path=ca_path) + return user_list + + +@fixture(scope="module") +def ldap_mongodb_agent_user( + multicluster_openldap_tls: OpenLDAP, ca_path: str +) -> LDAPUser: + user = LDAPUser(AUTOMATION_AGENT_NAME, LDAP_PASSWORD) + + ensure_organizational_unit(multicluster_openldap_tls, "groups", ca_path=ca_path) + create_user(multicluster_openldap_tls, user, ou="groups", ca_path=ca_path) + + ensure_group(multicluster_openldap_tls, cn="agents", ou="groups", ca_path=ca_path) + add_user_to_group( + multicluster_openldap_tls, + user=AUTOMATION_AGENT_NAME, + group_cn="agents", + ou="groups", + ca_path=ca_path, + ) + + return user + + +# more details https://istio.io/latest/docs/tasks/traffic-management/egress/egress-control/ +@fixture(scope="module") +def service_entries( + namespace: str, + central_cluster_client: kubernetes.client.ApiClient, + member_cluster_names: List[str], +) -> List[CustomObject]: + return create_service_entries_objects( + namespace, central_cluster_client, member_cluster_names + ) + + +def check_valid_ip(ip_str: str) -> bool: + try: + ipaddress.ip_address(ip_str) + return True + except ValueError: + return False + + +def create_service_entries_objects( + namespace: str, + central_cluster_client: kubernetes.client.ApiClient, + member_cluster_names: List[str], +) -> List[CustomObject]: + service_entries = [] + + allowed_hosts_service_entry = CustomObject( + name="allowed-hosts", + namespace=namespace, + kind="ServiceEntry", + plural="serviceentries", + group="networking.istio.io", + version="v1beta1", + api_client=central_cluster_client, + ) + + allowed_addresses_service_entry = CustomObject( + name="allowed-addresses", + namespace=namespace, + kind="ServiceEntry", + plural="serviceentries", + group="networking.istio.io", + version="v1beta1", + api_client=central_cluster_client, + ) + + api_servers = get_api_servers_from_test_pod_kubeconfig( + namespace, member_cluster_names + ) + + host_parse_results = [ + urllib.parse.urlparse(api_servers[member_cluster]) + for member_cluster in member_cluster_names + ] + + hosts = set(["cloud-qa.mongodb.com"]) + addresses = set() + ports = [{"name": "https", "number": 443, "protocol": "HTTPS"}] + + for host_parse_result in host_parse_results: + if host_parse_result.port is not None and host_parse_result.port != "": + ports.append( + { + "name": f"https-{host_parse_result.port}", + "number": host_parse_result.port, + "protocol": "HTTPS", + } + ) + if check_valid_ip(host_parse_result.hostname): + addresses.add(host_parse_result.hostname) + else: + hosts.add(host_parse_result.hostname) + + allowed_hosts_service_entry["spec"] = { + # by default the access mode is set to "REGISTRY_ONLY" which means only the hosts specified + # here would be accessible from the operator pod + "hosts": list(hosts), + "exportTo": ["."], + "location": "MESH_EXTERNAL", + "ports": ports, + "resolution": "DNS", + } + service_entries.append(allowed_hosts_service_entry) + + if len(addresses) > 0: + allowed_addresses_service_entry["spec"] = { + "hosts": ["kubernetes", "kubernetes-master", "kube-apiserver"], + "addresses": list(addresses), + "exportTo": ["."], + "location": "MESH_EXTERNAL", + "ports": ports, + # when allowing by IP address we do not want to resolve IP using HTTP host field + "resolution": "NONE", + } + service_entries.append(allowed_addresses_service_entry) + + return service_entries + + +def cluster_spec_list( + member_cluster_names: List[str], + members: List[int], + member_configs: List[Dict] = None, +): + if member_configs is None: + return [ + {"clusterName": name, "members": members} + for (name, members) in zip(member_cluster_names, members) + ] + else: + return [ + {"clusterName": name, "members": members, "memberConfig": memberConfig} + for (name, members, memberConfig) in zip( + member_cluster_names, members, member_configs + ) + ] diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-multi-central-sts-override.yaml b/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-multi-central-sts-override.yaml new file mode 100644 index 000000000..d8434d35f --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-multi-central-sts-override.yaml @@ -0,0 +1,31 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDBMultiCluster +metadata: + name: multi-replica-set +spec: + version: 4.4.0-ent + type: ReplicaSet + duplicateServiceObjects: false + credentials: my-credentials + statefulSet: + spec: + template: + spec: + # FIXME workaround for sleep infinity hanging + shareProcessNamespace: true + containers: + - name: sidecar1 + image: busybox + command: ["sleep"] + args: [ "infinity" ] + opsManager: + configMapRef: + name: my-project + clusterSpecList: + - clusterName: kind-e2e-cluster-1 + members: 2 + - clusterName: kind-e2e-cluster-2 + members: 1 + - clusterName: kind-e2e-cluster-3 + members: 2 diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-multi-cluster.yaml b/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-multi-cluster.yaml new file mode 100644 index 000000000..2e142bcb8 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-multi-cluster.yaml @@ -0,0 +1,20 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBMultiCluster +metadata: + name: multi-replica-set +spec: + version: 4.4.0-ent + type: ReplicaSet + persistent: false + duplicateServiceObjects: false + credentials: my-credentials + opsManager: + configMapRef: + name: my-project + clusterSpecList: + - clusterName: kind-e2e-cluster-1 + members: 2 + - clusterName: kind-e2e-cluster-2 + members: 1 + - clusterName: kind-e2e-cluster-3 + members: 2 diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-multi-dr.yaml b/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-multi-dr.yaml new file mode 100644 index 000000000..a44e78f3b --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-multi-dr.yaml @@ -0,0 +1,39 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDBMultiCluster +metadata: + name: multi-replica-set +spec: + version: 4.4.0-ent + type: ReplicaSet + persistent: false + credentials: my-credentials + duplicateServiceObjects: true + opsManager: + configMapRef: + name: my-project + clusterSpecList: + - clusterName: gke_k8s-rdas_us-east1-b_member-1a + members: 2 + statefulSet: + spec: + template: + spec: + securityContext: + fsGroup: 2000 + - clusterName: gke_k8s-rdas_us-east1-c_member-2a + members: 1 + statefulSet: + spec: + template: + spec: + securityContext: + fsGroup: 2000 + - clusterName: gke_k8s-rdas_us-west1-a_member-3a + members: 2 + statefulSet: + spec: + template: + spec: + securityContext: + fsGroup: 2000 diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-multi-split-horizon.yaml b/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-multi-split-horizon.yaml new file mode 100644 index 000000000..07c138a6c --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-multi-split-horizon.yaml @@ -0,0 +1,28 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDBMultiCluster +metadata: + name: multi-replica-set +spec: + connectivity: + replicaSetHorizons: + - "test-horizon": "ec2-52-56-69-123.eu-west-2.compute.amazonaws.com:30100" + - "test-horizon": "ec2-3-9-165-220.eu-west-2.compute.amazonaws.com:30100" + - "test-horizon": "ec2-3-10-22-163.eu-west-2.compute.amazonaws.com:30100" + + + version: 4.4.0-ent + type: ReplicaSet + persistent: true + duplicateServiceObjects: false + credentials: my-credentials + opsManager: + configMapRef: + name: my-project + clusterSpecList: + - clusterName: kind-e2e-cluster-1 + members: 1 + - clusterName: kind-e2e-cluster-2 + members: 1 + - clusterName: kind-e2e-cluster-3 + members: 1 diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-multi-sts-override.yaml b/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-multi-sts-override.yaml new file mode 100644 index 000000000..a5d4a1e4a --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-multi-sts-override.yaml @@ -0,0 +1,62 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDBMultiCluster +metadata: + name: multi-replica-set +spec: + version: 4.4.0-ent + type: ReplicaSet + duplicateServiceObjects: false + credentials: my-credentials + opsManager: + configMapRef: + name: my-project + clusterSpecList: + - clusterName: kind-e2e-cluster-1 + members: 2 + statefulSet: + spec: + template: + spec: + containers: + - name: sidecar1 + image: busybox + command: ["sleep"] + args: [ "infinity" ] + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: [ "ReadWriteOnce" ] + - clusterName: kind-e2e-cluster-2 + members: 1 + statefulSet: + spec: + template: + spec: + containers: + - name: sidecar2 + image: busybox + command: ["sleep"] + args: [ "infinity" ] + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: [ "ReadWriteOnce" ] + - clusterName: kind-e2e-cluster-3 + members: 1 + statefulSet: + spec: + template: + spec: + containers: + - name: sidecar3 + image: busybox + command: ["sleep"] + args: [ "infinity" ] + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: [ "ReadWriteOnce" ] diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-multi.yaml b/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-multi.yaml new file mode 100644 index 000000000..376caa441 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-multi.yaml @@ -0,0 +1,20 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDBMultiCluster +metadata: + name: multi-replica-set +spec: + version: 4.4.0-ent + type: ReplicaSet + duplicateServiceObjects: false + credentials: my-credentials + opsManager: + configMapRef: + name: my-project + clusterSpecList: + - clusterName: kind-e2e-cluster-1 + members: 2 + - clusterName: kind-e2e-cluster-2 + members: 1 + - clusterName: kind-e2e-cluster-3 + members: 2 diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-user.yaml b/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-user.yaml new file mode 100644 index 000000000..29d668543 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-user.yaml @@ -0,0 +1,22 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBUser +metadata: + name: mms-user-1 +spec: + passwordSecretKeyRef: + name: mms-user-1-password + key: password + username: "mms-user-1" + db: "admin" + mongodbResourceRef: + name: "multi-replica-set" + namespace: + roles: + - db: "admin" + name: "clusterAdmin" + - db: "admin" + name: "userAdminAnyDatabase" + - db: "admin" + name: "readWrite" + - db: "admin" + name: "userAdminAnyDatabase" diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-x509-user.yaml b/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-x509-user.yaml new file mode 100644 index 000000000..dbf799f3b --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/mongodb-x509-user.yaml @@ -0,0 +1,19 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDBUser +metadata: + name: test-x509-user +spec: + username: 'CN=x509-testing-user' + db: '$external' + mongodbResourceRef: + name: "multi-replica-set" + roles: + - db: "admin" + name: "clusterAdmin" + - db: "admin" + name: "userAdminAnyDatabase" + - db: "admin" + name: "readWrite" + - db: "admin" + name: "userAdminAnyDatabase" diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/split-horizon-node-port.yaml b/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/split-horizon-node-port.yaml new file mode 100644 index 000000000..0fa05bc18 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/split-horizon-node-port.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: my-service + labels: + controller: mongodb-enterprise-operator +spec: + type: NodePort + selector: + controller: mongodb-enterprise-operator + ports: + - port: 27017 + targetPort: 27017 + nodePort: 30007 diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/split-horizon-node-ports/split-horizon-node-port.yaml b/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/split-horizon-node-ports/split-horizon-node-port.yaml new file mode 100644 index 000000000..8f9c1b6f5 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/fixtures/split-horizon-node-ports/split-horizon-node-port.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: my-service + labels: + controller: mongodb-enterprise-operator + mongodbmulticluster: -multi-cluster-replica-set + statefulset.kubernetes.io/pod-name: multi-cluster-replica-set-0-0 +spec: + type: NodePort + selector: + controller: mongodb-enterprise-operator + statefulset.kubernetes.io/pod-name: multi-cluster-replica-set-0-0 + ports: + - port: 30100 + targetPort: 27017 + nodePort: 30100 diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/manual_multi_cluster_tls_no_mesh_2_clusters_eks_gke.py b/docker/mongodb-enterprise-tests/tests/multicluster/manual_multi_cluster_tls_no_mesh_2_clusters_eks_gke.py new file mode 100644 index 000000000..fd136d6fa --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/manual_multi_cluster_tls_no_mesh_2_clusters_eks_gke.py @@ -0,0 +1,144 @@ +# This is a manual test deploying MongoDBMultiCluster on 2 clusters in GKE and EKS. +# Steps to execute: +# 1. After the test is executed it will create external services of type LoadBalancer. All pods will be not ready +# due to lack of external connectivity. +# 2. Go to mc.mongokubernetes.com Route53 hosted zone: https://us-east-1.console.aws.amazon.com/route53/v2/hostedzones#ListRecordSets/Z04069951X9SBFR8OQUFM +# 3. Copy provisioned hostnames from external services in EKS and update CNAME records for: +# * multi-cluster-rs-0-0.aws-member-cluster.eks.mc.mongokubernetes.com +# * multi-cluster-rs-0-1.aws-member-cluster.eks.mc.mongokubernetes.com +# 4. Copy IP addresses of external services in GKE and update A record for: +# * multi-cluster-rs-1-0.gke-member-cluster.gke.mc.mongokubernetes.com +# * multi-cluster-rs-1-1.gke-member-cluster.gke.mc.mongokubernetes.com +# 5. After few minutes everything should be ready. + +from typing import List + +import kubernetes +from kubetester import create_or_update +from kubetester.certs import create_multi_cluster_mongodb_tls_certs +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.mongodb import Phase +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from kubetester.operator import Operator +from pytest import mark, fixture +from tests.multicluster.conftest import cluster_spec_list + +CERT_SECRET_PREFIX = "clustercert" +MDB_RESOURCE = "multi-cluster-rs" +BUNDLE_SECRET_NAME = f"{CERT_SECRET_PREFIX}-{MDB_RESOURCE}-cert" +BUNDLE_PEM_SECRET_NAME = f"{CERT_SECRET_PREFIX}-{MDB_RESOURCE}-cert-pem" + + +@fixture(scope="module") +def cert_additional_domains() -> list[str]: + return [ + "*.gke-member-cluster.gke.mc.mongokubernetes.com", + "*.aws-member-cluster.eks.mc.mongokubernetes.com", + ] + + +@fixture(scope="module") +def mongodb_multi_unmarshalled(namespace: str, member_cluster_names: List[str]) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml(yaml_fixture("mongodb-multi.yaml"), MDB_RESOURCE, namespace) + resource["spec"]["persistent"] = False + # These domains map 1:1 to the CoreDNS file. Please be mindful when updating them. + resource["spec"]["clusterSpecList"] = cluster_spec_list(member_cluster_names, [2, 1]) + + resource["spec"]["externalAccess"] = {} + resource["spec"]["clusterSpecList"][0]["externalAccess"] = { + "externalDomain": "aws-member-cluster.eks.mc.mongokubernetes.com", + "externalService": { + "annotations": {"cloud.google.com/l4-rbs": "enabled"}, + "spec": { + "type": "LoadBalancer", + "publishNotReadyAddresses": True, + "ports": [ + { + "name": "mongodb", + "port": 27017, + }, + { + "name": "backup", + "port": 27018, + }, + ], + }, + }, + } + resource["spec"]["clusterSpecList"][1]["externalAccess"] = { + "externalDomain": "gke-member-cluster.gke.mc.mongokubernetes.com", + "externalService": { + "annotations": { + "service.beta.kubernetes.io/aws-load-balancer-type": "external", + "service.beta.kubernetes.io/aws-load-balancer-nlb-target-type": "instance", + "service.beta.kubernetes.io/aws-load-balancer-scheme": "internet-facing", + }, + "spec": { + "type": "LoadBalancer", + "publishNotReadyAddresses": True, + "ports": [ + { + "name": "mongodb", + "port": 27017, + }, + { + "name": "backup", + "port": 27018, + }, + ], + }, + }, + } + + return resource + + +@fixture(scope="function") +def mongodb_multi( + central_cluster_client: kubernetes.client.ApiClient, + namespace: str, + mongodb_multi_unmarshalled: MongoDBMulti, + multi_cluster_issuer_ca_configmap: str, +) -> MongoDBMulti: + mongodb_multi_unmarshalled["spec"]["security"] = { + "certsSecretPrefix": CERT_SECRET_PREFIX, + "tls": { + "ca": multi_cluster_issuer_ca_configmap, + }, + } + mongodb_multi_unmarshalled.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + + return create_or_update(mongodb_multi_unmarshalled) + + +@fixture(scope="module") +def server_certs( + multi_cluster_issuer: str, + mongodb_multi_unmarshalled: MongoDBMulti, + member_cluster_clients: list[MultiClusterClient], + central_cluster_client: kubernetes.client.ApiClient, + cert_additional_domains: list[str], +): + return create_multi_cluster_mongodb_tls_certs( + multi_cluster_issuer, + BUNDLE_SECRET_NAME, + member_cluster_clients, + central_cluster_client, + mongodb_multi_unmarshalled, + additional_domains=cert_additional_domains, + ) + + +def test_deploy_operator(multi_cluster_operator: Operator): + multi_cluster_operator.assert_is_running() + + +def test_create_mongodb_multi( + mongodb_multi: MongoDBMulti, + namespace: str, + server_certs: str, + multi_cluster_issuer_ca_configmap: str, + member_cluster_clients: List[MultiClusterClient], + member_cluster_names: List[str], +): + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=2400) diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_2_cluster_clusterwide_replicaset.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_2_cluster_clusterwide_replicaset.py new file mode 100644 index 000000000..160db4241 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_2_cluster_clusterwide_replicaset.py @@ -0,0 +1,327 @@ +from typing import Dict, List + +import kubernetes +import pytest +from kubetester.certs import create_multi_cluster_mongodb_tls_certs +from kubetester.mongodb import Phase +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from kubetester.operator import Operator +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.kubetester import create_testing_namespace +from tests.conftest import ( + run_kube_config_creation_tool, + _install_multi_cluster_operator, + MULTI_CLUSTER_OPERATOR_NAME, +) +from kubetester import ( + create_or_update_secret, + create_or_update_configmap, + create_or_update, + read_secret, + read_configmap, +) +from .conftest import cluster_spec_list +from . import prepare_multi_cluster_namespaces + +CERT_SECRET_PREFIX = "clustercert" +MDB_RESOURCE = "multi-cluster-replica-set" +BUNDLE_SECRET_NAME = f"{CERT_SECRET_PREFIX}-{MDB_RESOURCE}-cert" + + +@pytest.fixture(scope="module") +def mdba_ns(namespace: str): + return "{}-mdb-ns-a".format(namespace) + + +@pytest.fixture(scope="module") +def mdbb_ns(namespace: str): + return "{}-mdb-ns-b".format(namespace) + + +def create_namespace( + central_cluster_client: kubernetes.client.ApiClient, + member_cluster_clients: List[MultiClusterClient], + task_id: str, + namespace: str, + image_pull_secret_name: str, + image_pull_secret_data: Dict[str, str], +) -> str: + for client in member_cluster_clients: + create_testing_namespace(task_id, namespace, client.api_client, True) + create_or_update_secret( + namespace, + image_pull_secret_name, + image_pull_secret_data, + type="kubernetes.io/dockerconfigjson", + api_client=client.api_client, + ) + + create_testing_namespace(task_id, namespace, central_cluster_client) + create_or_update_secret( + namespace, + image_pull_secret_name, + image_pull_secret_data, + type="kubernetes.io/dockerconfigjson", + api_client=client.api_client, + ) + + return namespace + + +@pytest.fixture(scope="module") +def mongodb_multi_a_unmarshalled( + central_cluster_client: kubernetes.client.ApiClient, + mdba_ns: str, + member_cluster_names: List[str], +) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml( + yaml_fixture("mongodb-multi.yaml"), "multi-replica-set", mdba_ns + ) + + resource["spec"]["clusterSpecList"] = cluster_spec_list( + member_cluster_names, [2, 1] + ) + return resource + + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + create_or_update(resource) + return resource + + +@pytest.fixture(scope="module") +def mongodb_multi_b_unmarshalled( + central_cluster_client: kubernetes.client.ApiClient, + mdbb_ns: str, + member_cluster_names: List[str], +) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml( + yaml_fixture("mongodb-multi.yaml"), "multi-replica-set", mdbb_ns + ) + resource["spec"]["clusterSpecList"] = cluster_spec_list( + member_cluster_names, [2, 1] + ) + + return resource + + +@pytest.fixture(scope="module") +def server_certs_a( + multi_cluster_clusterissuer: str, + mongodb_multi_a_unmarshalled: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], + central_cluster_client: kubernetes.client.ApiClient, +): + + return create_multi_cluster_mongodb_tls_certs( + multi_cluster_clusterissuer, + f"{CERT_SECRET_PREFIX}-{mongodb_multi_a_unmarshalled.name}-cert", + member_cluster_clients, + central_cluster_client, + mongodb_multi_a_unmarshalled, + clusterwide=True, + ) + + +@pytest.fixture(scope="module") +def server_certs_b( + multi_cluster_clusterissuer: str, + mongodb_multi_b_unmarshalled: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], + central_cluster_client: kubernetes.client.ApiClient, +): + + return create_multi_cluster_mongodb_tls_certs( + multi_cluster_clusterissuer, + f"{CERT_SECRET_PREFIX}-{mongodb_multi_b_unmarshalled.name}-cert", + member_cluster_clients, + central_cluster_client, + mongodb_multi_b_unmarshalled, + clusterwide=True, + ) + + +@pytest.fixture(scope="module") +def mongodb_multi_a( + central_cluster_client: kubernetes.client.ApiClient, + mdba_ns: str, + server_certs_a: str, + mongodb_multi_a_unmarshalled: MongoDBMulti, + issuer_ca_filepath: str, + # multi_cluster_issuer_ca_configmap: str, +) -> MongoDBMulti: + ca = open(issuer_ca_filepath).read() + + # The operator expects the CA that validates Ops Manager is contained in + # an entry with a name of "mms-ca.crt" + data = {"ca-pem": ca, "mms-ca.crt": ca} + name = "issuer-ca" + + create_or_update_configmap(mdba_ns, name, data, api_client=central_cluster_client) + + resource = mongodb_multi_a_unmarshalled + resource["spec"]["security"] = { + "certsSecretPrefix": CERT_SECRET_PREFIX, + "tls": { + "ca": name, + }, + } + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + create_or_update(resource) + return resource + + +@pytest.fixture(scope="module") +def mongodb_multi_b( + central_cluster_client: kubernetes.client.ApiClient, + mdbb_ns: str, + server_certs_b: str, + mongodb_multi_b_unmarshalled: MongoDBMulti, + issuer_ca_filepath: str, + # multi_cluster_issuer_ca_configmap: str, +) -> MongoDBMulti: + ca = open(issuer_ca_filepath).read() + + # The operator expects the CA that validates Ops Manager is contained in + # an entry with a name of "mms-ca.crt" + data = {"ca-pem": ca, "mms-ca.crt": ca} + name = "issuer-ca" + + create_or_update_configmap(mdbb_ns, name, data, api_client=central_cluster_client) + + resource = mongodb_multi_b_unmarshalled + resource["spec"]["security"] = { + "certsSecretPrefix": CERT_SECRET_PREFIX, + "tls": { + "ca": name, + }, + } + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + create_or_update(resource) + return resource + + +@pytest.mark.e2e_multi_cluster_2_clusters_clusterwide +def test_create_kube_config_file( + cluster_clients: Dict, member_cluster_names: List[str] +): + clients = cluster_clients + + assert len(clients) == 2 + assert member_cluster_names[0] in clients + assert member_cluster_names[1] in clients + + +@pytest.mark.e2e_multi_cluster_2_clusters_clusterwide +def test_create_namespaces( + namespace: str, + mdba_ns: str, + mdbb_ns: str, + central_cluster_client: kubernetes.client.ApiClient, + member_cluster_clients: List[MultiClusterClient], + evergreen_task_id: str, + multi_cluster_operator_installation_config: Dict[str, str], +): + image_pull_secret_name = multi_cluster_operator_installation_config[ + "registry.imagePullSecrets" + ] + image_pull_secret_data = read_secret( + namespace, image_pull_secret_name, api_client=central_cluster_client + ) + + create_namespace( + central_cluster_client, + member_cluster_clients, + evergreen_task_id, + mdba_ns, + image_pull_secret_name, + image_pull_secret_data, + ) + + create_namespace( + central_cluster_client, + member_cluster_clients, + evergreen_task_id, + mdbb_ns, + image_pull_secret_name, + image_pull_secret_data, + ) + + +@pytest.mark.e2e_multi_cluster_2_clusters_clusterwide +def test_deploy_operator(multi_cluster_operator_clustermode: Operator): + multi_cluster_operator_clustermode.assert_is_running() + + +@pytest.mark.e2e_multi_cluster_2_clusters_clusterwide +def test_prepare_namespace( + multi_cluster_operator_installation_config: Dict[str, str], + member_cluster_clients: List[MultiClusterClient], + central_cluster_name: str, + mdba_ns: str, + mdbb_ns: str, +): + prepare_multi_cluster_namespaces( + mdba_ns, + multi_cluster_operator_installation_config, + member_cluster_clients, + central_cluster_name, + skip_central_cluster=False, + ) + + prepare_multi_cluster_namespaces( + mdbb_ns, + multi_cluster_operator_installation_config, + member_cluster_clients, + central_cluster_name, + skip_central_cluster=False, + ) + + +@pytest.mark.e2e_multi_cluster_2_clusters_clusterwide +def test_copy_configmap_and_secret_across_ns( + namespace: str, + central_cluster_client: kubernetes.client.ApiClient, + multi_cluster_operator_installation_config: Dict[str, str], + mdba_ns: str, + mdbb_ns: str, +): + data = read_configmap(namespace, "my-project", api_client=central_cluster_client) + data["projectName"] = mdba_ns + create_or_update_configmap( + mdba_ns, "my-project", data, api_client=central_cluster_client + ) + + data["projectName"] = mdbb_ns + create_or_update_configmap( + mdbb_ns, "my-project", data, api_client=central_cluster_client + ) + + data = read_secret(namespace, "my-credentials", api_client=central_cluster_client) + create_or_update_secret( + mdba_ns, "my-credentials", data, api_client=central_cluster_client + ) + create_or_update_secret( + mdbb_ns, "my-credentials", data, api_client=central_cluster_client + ) + + +@pytest.mark.e2e_multi_cluster_2_clusters_clusterwide +def test_create_mongodb_multi_nsa(mongodb_multi_a: MongoDBMulti): + mongodb_multi_a.assert_reaches_phase(Phase.Running, timeout=800) + + +@pytest.mark.e2e_multi_cluster_2_clusters_clusterwide +def test_enable_mongodb_multi_nsa_auth(mongodb_multi_a: MongoDBMulti): + mongodb_multi_a.reload() + mongodb_multi_a["spec"]["authentication"] = ( + { + "agents": {"mode": "SCRAM"}, + "enabled": True, + "modes": ["SCRAM"], + }, + ) + + +@pytest.mark.e2e_multi_cluster_2_clusters_clusterwide +def test_create_mongodb_multi_nsb(mongodb_multi_b: MongoDBMulti): + mongodb_multi_b.assert_reaches_phase(Phase.Running, timeout=800) diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_2_cluster_replicaset.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_2_cluster_replicaset.py new file mode 100644 index 000000000..01e73227b --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_2_cluster_replicaset.py @@ -0,0 +1,101 @@ +from typing import Dict, List + +import kubernetes +import pytest +from kubetester.certs import create_multi_cluster_mongodb_tls_certs, Certificate +from kubetester.mongodb import Phase +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from kubetester.operator import Operator +from kubetester.kubetester import fixture as yaml_fixture, skip_if_local +from kubetester.mongotester import with_tls + +from .conftest import cluster_spec_list + +CERT_SECRET_PREFIX = "clustercert" +MDB_RESOURCE = "multi-cluster-replica-set" +BUNDLE_SECRET_NAME = f"{CERT_SECRET_PREFIX}-{MDB_RESOURCE}-cert" + + +@pytest.fixture(scope="module") +def mongodb_multi_unmarshalled(namespace: str, member_cluster_names: List[str]) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml(yaml_fixture("mongodb-multi.yaml"), MDB_RESOURCE, namespace) + resource["spec"]["clusterSpecList"] = cluster_spec_list(member_cluster_names, [2, 1]) + return resource + + +@pytest.fixture(scope="module") +def server_certs( + multi_cluster_issuer: str, + mongodb_multi_unmarshalled: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], + central_cluster_client: kubernetes.client.ApiClient, +): + + return create_multi_cluster_mongodb_tls_certs( + multi_cluster_issuer, + BUNDLE_SECRET_NAME, + member_cluster_clients, + central_cluster_client, + mongodb_multi_unmarshalled, + ) + + +@pytest.fixture(scope="module") +def mongodb_multi( + central_cluster_client: kubernetes.client.ApiClient, + server_certs: str, + mongodb_multi_unmarshalled: MongoDBMulti, + multi_cluster_issuer_ca_configmap: str, +) -> MongoDBMulti: + resource = mongodb_multi_unmarshalled + + resource["spec"]["security"] = { + "certsSecretPrefix": CERT_SECRET_PREFIX, + "tls": { + "ca": multi_cluster_issuer_ca_configmap, + }, + } + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + + return resource.create() + + +@pytest.mark.e2e_multi_cluster_2_clusters_replica_set +def test_create_kube_config_file(cluster_clients: Dict, member_cluster_names: List[str]): + clients = cluster_clients + + assert len(clients) == 2 + assert member_cluster_names[0] in clients + assert member_cluster_names[1] in clients + + +@pytest.mark.e2e_multi_cluster_2_clusters_replica_set +def test_deploy_operator(multi_cluster_operator: Operator): + multi_cluster_operator.assert_is_running() + + +@pytest.mark.e2e_multi_cluster_2_clusters_replica_set +def test_create_mongodb_multi(mongodb_multi: MongoDBMulti): + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=1200) + + +@pytest.mark.e2e_multi_cluster_2_clusters_replica_set +def test_statefulset_is_created_across_multiple_clusters( + mongodb_multi: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], +): + statefulsets = mongodb_multi.read_statefulsets(member_cluster_clients) + cluster_one_client = member_cluster_clients[0] + cluster_one_sts = statefulsets[cluster_one_client.cluster_name] + assert cluster_one_sts.status.ready_replicas == 2 + + cluster_two_client = member_cluster_clients[1] + cluster_two_sts = statefulsets[cluster_two_client.cluster_name] + assert cluster_two_sts.status.ready_replicas == 1 + + +@skip_if_local +@pytest.mark.e2e_multi_cluster_2_clusters_replica_set +def test_replica_set_is_reachable(mongodb_multi: MongoDBMulti, ca_path: str): + tester = mongodb_multi.tester() + tester.assert_connectivity(opts=[with_tls(use_tls=True, ca_path=ca_path)]) diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_agent_flags.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_agent_flags.py new file mode 100644 index 000000000..a427c984a --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_agent_flags.py @@ -0,0 +1,52 @@ +from typing import List +from pytest import mark, fixture + +from kubetester import create_or_update +from kubetester.mongodb import Phase +import kubernetes +from kubetester.kubetester import fixture as yaml_fixture, KubernetesTester +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from kubetester.operator import Operator +from tests.multicluster.conftest import cluster_spec_list + + +@fixture(scope="module") +def mongodb_multi( + central_cluster_client: kubernetes.client.ApiClient, + namespace: str, + member_cluster_names: list[str], +) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml(yaml_fixture("mongodb-multi-cluster.yaml"), "multi-replica-set", namespace) + resource["spec"]["clusterSpecList"] = cluster_spec_list(member_cluster_names, [2, 1, 2]) + + # override agent startup flags + resource["spec"]["agent"] = {"startupOptions": {"logFile": "/var/log/mongodb-mms-automation/customLogFile"}} + + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + return create_or_update(resource) + + +@mark.e2e_multi_cluster_agent_flags +def test_create_mongodb_multi(multi_cluster_operator: Operator, mongodb_multi: MongoDBMulti): + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=700) + + +@mark.e2e_multi_cluster_agent_flags +def test_multi_replicaset_has_agent_flags( + namespace: str, + member_cluster_clients: List[MultiClusterClient], +): + cluster_1_client = member_cluster_clients[0] + cmd = [ + "/bin/sh", + "-c", + "ls /var/log/mongodb-mms-automation/customLogFile* | wc -l", + ] + result = KubernetesTester.run_command_in_pod_container( + "multi-replica-set-0-0", + namespace, + cmd, + container="mongodb-enterprise-database", + api_client=cluster_1_client.api_client, + ) + assert result != "0" diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_automated_disaster_recovery.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_automated_disaster_recovery.py new file mode 100644 index 000000000..8e270b114 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_automated_disaster_recovery.py @@ -0,0 +1,125 @@ +from typing import Dict, List +from pytest import mark, fixture + +import kubernetes +from kubetester.mongodb import Phase +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from kubetester.operator import Operator +from kubetester.kubetester import KubernetesTester, fixture as yaml_fixture +from kubernetes import client +from kubeobject import CustomObject +import time + +from kubetester import delete_pod, get_pod_when_ready, create_or_update +from kubetester.automation_config_tester import AutomationConfigTester +from .conftest import create_service_entries_objects, cluster_spec_list + + +@fixture(scope="module") +def mongodb_multi( + central_cluster_client: kubernetes.client.ApiClient, + namespace: str, + member_cluster_names: list[str], +) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml(yaml_fixture("mongodb-multi.yaml"), "multi-replica-set", namespace) + resource["spec"]["persistent"] = False + resource["spec"]["clusterSpecList"] = cluster_spec_list(member_cluster_names, [2, 1, 2]) + + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + + return resource + + +@mark.e2e_multi_cluster_disaster_recovery +def test_label_namespace(namespace: str, central_cluster_client: kubernetes.client.ApiClient): + api = client.CoreV1Api(api_client=central_cluster_client) + + labels = {"istio-injection": "enabled"} + ns = api.read_namespace(name=namespace) + + ns.metadata.labels.update(labels) + api.replace_namespace(name=namespace, body=ns) + + +@mark.e2e_multi_cluster_disaster_recovery +def test_create_service_entry(service_entries: List[CustomObject]): + for service_entry in service_entries: + create_or_update(service_entry) + + +@mark.e2e_multi_cluster_disaster_recovery +@mark.e2e_multi_cluster_multi_disaster_recovery +def test_deploy_operator(multi_cluster_operator: Operator): + multi_cluster_operator.assert_is_running() + + +@mark.e2e_multi_cluster_disaster_recovery +@mark.e2e_multi_cluster_multi_disaster_recovery +def test_create_mongodb_multi(mongodb_multi: MongoDBMulti): + create_or_update(mongodb_multi) + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=1200) + + +@mark.e2e_multi_cluster_disaster_recovery +def test_update_service_entry_block_cluster3_traffic( + namespace: str, + central_cluster_client: kubernetes.client.ApiClient, + member_cluster_names: List[str], +): + service_entries = create_service_entries_objects( + namespace, + central_cluster_client, + [member_cluster_names[0], member_cluster_names[1]], + ) + for service_entry in service_entries: + print(f"service_entry={service_entries}") + service_entry.update() + + +@mark.e2e_multi_cluster_disaster_recovery +def test_mongodb_multi_leaves_running_state( + mongodb_multi: MongoDBMulti, +): + mongodb_multi.load() + mongodb_multi.assert_abandons_phase(Phase.Running, timeout=100) + + +@mark.e2e_multi_cluster_disaster_recovery +@mark.e2e_multi_cluster_multi_disaster_recovery +def test_replica_set_is_reachable(mongodb_multi: MongoDBMulti): + tester = mongodb_multi.tester() + tester.assert_connectivity() + + +@mark.e2e_multi_cluster_disaster_recovery +def test_replica_reaches_running(mongodb_multi: MongoDBMulti): + mongodb_multi.load() + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=700) + + +@mark.e2e_multi_cluster_disaster_recovery +@mark.e2e_multi_cluster_multi_disaster_recovery +def test_number_numbers_in_ac(mongodb_multi: MongoDBMulti): + tester = AutomationConfigTester(KubernetesTester.get_automation_config()) + desiredmembers = 0 + for c in mongodb_multi["spec"]["clusterSpecList"]: + desiredmembers += c["members"] + + processes = tester.get_replica_set_processes(mongodb_multi.name) + assert len(processes) == desiredmembers + + +@mark.e2e_multi_cluster_disaster_recovery +def test_sts_count_in_member_cluster( + mongodb_multi: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], +): + # assert the distribution of member cluster3 nodes. + statefulsets = mongodb_multi.read_statefulsets(member_cluster_clients) + cluster_one_client = member_cluster_clients[0] + cluster_one_sts = statefulsets[cluster_one_client.cluster_name] + assert cluster_one_sts.status.ready_replicas == 3 + + cluster_two_client = member_cluster_clients[1] + cluster_two_sts = statefulsets[cluster_two_client.cluster_name] + assert cluster_two_sts.status.ready_replicas == 2 diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_backup_restore.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_backup_restore.py new file mode 100644 index 000000000..70e547198 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_backup_restore.py @@ -0,0 +1,534 @@ +import datetime +import time +from typing import List +from typing import Optional, Dict + +import kubernetes +import kubernetes.client +from kubernetes import client +from pymongo.errors import ServerSelectionTimeoutError +from pytest import mark, fixture + +from kubetester import ( + create_or_update, + create_or_update_configmap, +) +from kubetester import create_or_update_secret, try_load +from kubetester import ( + get_default_storage_class, + read_service, +) +from kubetester.certs import create_ops_manager_tls_certs +from kubetester.kubetester import ( + skip_if_local, + fixture as yaml_fixture, + KubernetesTester, +) +from kubetester.mongodb import Phase, MongoDB +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from kubetester.mongodb_user import MongoDBUser +from kubetester.omtester import OMTester +from kubetester.operator import Operator +from kubetester.opsmanager import MongoDBOpsManager +from tests.conftest import update_coredns_hosts + +TEST_DATA = {"name": "John", "address": "Highway 37", "age": 30} +MONGODB_PORT = 30000 + + +HEAD_PATH = "/head/" +OPLOG_RS_NAME = "my-mongodb-oplog" +BLOCKSTORE_RS_NAME = "my-mongodb-blockstore" +USER_PASSWORD = "/qwerty@!#:" + + +@fixture(scope="module") +def ops_manager_certs( + namespace: str, + multi_cluster_issuer: str, + central_cluster_client: kubernetes.client.ApiClient, +): + return create_ops_manager_tls_certs( + multi_cluster_issuer, + namespace, + "om-backup", + secret_name="mdb-om-backup-cert", + # We need the interconnected certificate since we update coreDNS later with that ip -> domain + # because our central cluster is not part of the mesh, but we can access the pods via external IPs. + # Since we are using TLS we need a certificate for a hostname, an IP does not work, hence + # f"om-backup.{namespace}.interconnected" -> IP setup below + additional_domains=["fastdl.mongodb.org", f"om-backup.{namespace}.interconnected"], + api_client=central_cluster_client, + ) + + +def create_project_config_map(om: MongoDBOpsManager, mdb_name, project_name, client, custom_ca): + name = f"{mdb_name}-config" + data = { + "baseUrl": om.om_status().get_url(), + "projectName": project_name, + "sslMMSCAConfigMap": custom_ca, + "orgId": "", + } + + create_or_update_configmap(om.namespace, name, data, client) + + +def new_om_data_store( + mdb: MongoDB, + id: str, + assignment_enabled: bool = True, + user_name: Optional[str] = None, + password: Optional[str] = None, +) -> Dict: + return { + "id": id, + "uri": mdb.mongo_uri(user_name=user_name, password=password), + "ssl": mdb.is_tls_enabled(), + "assignmentEnabled": assignment_enabled, + } + + +@fixture(scope="module") +def ops_manager( + namespace: str, + multi_cluster_issuer_ca_configmap: str, + custom_appdb_version: str, + ops_manager_certs: str, + central_cluster_client: kubernetes.client.ApiClient, +) -> MongoDBOpsManager: + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_backup.yaml"), namespace=namespace + ) + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + resource["spec"]["externalConnectivity"] = {"type": "LoadBalancer"} + resource["spec"]["security"] = { + "certsSecretPrefix": "mdb", + "tls": {"ca": multi_cluster_issuer_ca_configmap}, + } + # remove s3 config + del resource["spec"]["backup"]["s3Stores"] + + resource.allow_mdb_rc_versions() + resource.create_admin_secret(api_client=central_cluster_client) + + try_load(resource) + + return resource + + +@fixture(scope="module") +def oplog_replica_set( + ops_manager, + namespace, + custom_mdb_version: str, + central_cluster_client: kubernetes.client.ApiClient, + multi_cluster_issuer_ca_configmap: str, +) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=OPLOG_RS_NAME, + ) + + create_project_config_map( + om=ops_manager, + project_name="development", + mdb_name=OPLOG_RS_NAME, + client=central_cluster_client, + custom_ca=multi_cluster_issuer_ca_configmap, + ) + + resource.configure(ops_manager, "development") + + resource["spec"]["opsManager"]["configMapRef"]["name"] = OPLOG_RS_NAME + "-config" + resource["spec"]["version"] = custom_mdb_version + + resource["spec"]["security"] = {"authentication": {"enabled": True, "modes": ["SCRAM"]}} + + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + yield create_or_update(resource) + + +@fixture(scope="module") +def blockstore_replica_set( + ops_manager, + namespace, + custom_mdb_version: str, + central_cluster_client: kubernetes.client.ApiClient, + multi_cluster_issuer_ca_configmap: str, +) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=BLOCKSTORE_RS_NAME, + ) + + create_project_config_map( + om=ops_manager, + project_name="blockstore", + mdb_name=BLOCKSTORE_RS_NAME, + client=central_cluster_client, + custom_ca=multi_cluster_issuer_ca_configmap, + ) + + resource.configure(ops_manager, "blockstore") + + resource["spec"]["version"] = custom_mdb_version + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + yield create_or_update(resource) + + +@fixture(scope="module") +def blockstore_user( + namespace, + blockstore_replica_set: MongoDB, + central_cluster_client: kubernetes.client.ApiClient, +) -> MongoDBUser: + """Creates a password secret and then the user referencing it""" + resource = MongoDBUser.from_yaml(yaml_fixture("scram-sha-user-backing-db.yaml"), namespace=namespace) + resource["spec"]["mongodbResourceRef"]["name"] = blockstore_replica_set.name + + print(f"\nCreating password for MongoDBUser {resource.name} in secret/{resource.get_secret_name()} ") + create_or_update_secret( + KubernetesTester.get_namespace(), + resource.get_secret_name(), + { + "password": USER_PASSWORD, + }, + api_client=central_cluster_client, + ) + + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + yield create_or_update(resource) + + +@fixture(scope="module") +def oplog_user( + namespace, + oplog_replica_set: MongoDB, + central_cluster_client: kubernetes.client.ApiClient, +) -> MongoDBUser: + """Creates a password secret and then the user referencing it""" + resource = MongoDBUser.from_yaml( + yaml_fixture("scram-sha-user-backing-db.yaml"), + namespace=namespace, + name="mms-user-2", + ) + resource["spec"]["mongodbResourceRef"]["name"] = oplog_replica_set.name + resource["spec"]["passwordSecretKeyRef"]["name"] = "mms-user-2-password" + resource["spec"]["username"] = "mms-user-2" + + print(f"\nCreating password for MongoDBUser {resource.name} in secret/{resource.get_secret_name()} ") + create_or_update_secret( + KubernetesTester.get_namespace(), + resource.get_secret_name(), + { + "password": USER_PASSWORD, + }, + api_client=central_cluster_client, + ) + + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + yield create_or_update(resource) + + +@mark.e2e_multi_cluster_backup_restore +def test_deploy_operator(multi_cluster_operator: Operator): + multi_cluster_operator.assert_is_running() + + +@mark.e2e_multi_cluster_backup_restore +class TestOpsManagerCreation: + """ + name: Ops Manager successful creation with backup and oplog stores enabled + description: | + Creates an Ops Manager instance with backup enabled. The OM is expected to get to 'Pending' state + eventually as it will wait for oplog db to be created + """ + + def test_create_om( + self, + ops_manager: MongoDBOpsManager, + ): + ops_manager["spec"]["backup"]["headDB"]["storageClass"] = get_default_storage_class() + ops_manager["spec"]["backup"]["members"] = 1 + + create_or_update(ops_manager) + + ops_manager.backup_status().assert_reaches_phase( + Phase.Pending, + msg_regexp="The MongoDB object .+ doesn't exist", + timeout=1800, + ) + + def test_daemon_statefulset( + self, + ops_manager: MongoDBOpsManager, + central_cluster_client: kubernetes.client.ApiClient, + ): + def stateful_set_becomes_ready(): + stateful_set = ops_manager.read_backup_statefulset(central_cluster_client) + return stateful_set.status.ready_replicas == 1 and stateful_set.status.current_replicas == 1 + + KubernetesTester.wait_until(stateful_set_becomes_ready, timeout=300) + + stateful_set = ops_manager.read_backup_statefulset(central_cluster_client) + # pod template has volume mount request + assert (HEAD_PATH, "head") in ( + (mount.mount_path, mount.name) for mount in stateful_set.spec.template.spec.containers[0].volume_mounts + ) + + def test_backup_daemon_services_created( + self, + namespace, + central_cluster_client: kubernetes.client.ApiClient, + ): + """Backup creates two additional services for queryable backup""" + services = client.CoreV1Api(api_client=central_cluster_client).list_namespaced_service(namespace).items + + backup_services = [s for s in services if s.metadata.name.startswith("om-backup")] + + assert len(backup_services) >= 3 + + +@mark.e2e_multi_cluster_backup_restore +class TestBackupDatabasesAdded: + """name: Creates mongodb resources for oplog and blockstore and waits until OM resource gets to + running state""" + + def test_backup_mdbs_created( + self, + oplog_replica_set: MongoDB, + blockstore_replica_set: MongoDB, + ): + """Creates mongodb databases all at once""" + oplog_replica_set.assert_reaches_phase(Phase.Running) + blockstore_replica_set.assert_reaches_phase(Phase.Running) + + def test_oplog_user_created(self, oplog_user: MongoDBUser): + oplog_user.assert_reaches_phase(Phase.Updated) + + def test_om_failed_oplog_no_user_ref(self, ops_manager: MongoDBOpsManager): + """Waits until Backup is in failed state as blockstore doesn't have reference to the user""" + ops_manager.backup_status().assert_reaches_phase( + Phase.Failed, + msg_regexp=".*is configured to use SCRAM-SHA authentication mode, the user " + "must be specified using 'mongodbUserRef'", + ) + + def test_fix_om(self, ops_manager: MongoDBOpsManager, oplog_user: MongoDBUser): + ops_manager.load() + ops_manager["spec"]["backup"]["opLogStores"][0]["mongodbUserRef"] = {"name": oplog_user.name} + ops_manager.update() + + ops_manager.backup_status().assert_reaches_phase( + Phase.Running, + timeout=200, + ignore_errors=True, + ) + + assert ops_manager.backup_status().get_message() is None + + +class TestBackupForMongodb: + @fixture(scope="module") + def base_url( + self, + ops_manager: MongoDBOpsManager, + ) -> str: + """ + The base_url makes OM accessible from member clusters via a special interconnected dns address. + This address only works for member clusters. + """ + interconnected_field = f"https://om-backup.{ops_manager.namespace}.interconnected" + new_address = f"{interconnected_field}:8443" + + return new_address + + @fixture(scope="module") + def project_one( + self, + ops_manager: MongoDBOpsManager, + namespace: str, + central_cluster_client: kubernetes.client.ApiClient, + base_url: str, + ) -> OMTester: + return ops_manager.get_om_tester( + project_name=f"{namespace}-project-one", + api_client=central_cluster_client, + base_url=base_url, + ) + + @fixture(scope="module") + def mongodb_multi_one_collection(self, mongodb_multi_one: MongoDBMulti): + collection = mongodb_multi_one.tester(port=MONGODB_PORT).client["testdb"] + return collection["testcollection"] + + @fixture(scope="module") + def mongodb_multi_one( + self, + ops_manager: MongoDBOpsManager, + multi_cluster_issuer_ca_configmap: str, + central_cluster_client: kubernetes.client.ApiClient, + namespace: str, + member_cluster_names: List[str], + base_url, + ) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml( + yaml_fixture("mongodb-multi.yaml"), + "multi-replica-set-one", + namespace + # the project configmap should be created in the central cluster. + ).configure(ops_manager, f"{namespace}-project-one", api_client=central_cluster_client) + + resource["spec"]["clusterSpecList"] = [ + {"clusterName": member_cluster_names[0], "members": 2}, + {"clusterName": member_cluster_names[1], "members": 1}, + {"clusterName": member_cluster_names[2], "members": 2}, + ] + + # creating a cluster with backup should work with custom ports + resource["spec"].update({"additionalMongodConfig": {"net": {"port": MONGODB_PORT}}}) + + resource.configure_backup(mode="enabled") + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + + data = KubernetesTester.read_configmap( + namespace, "multi-replica-set-one-config", api_client=central_cluster_client + ) + KubernetesTester.delete_configmap(namespace, "multi-replica-set-one-config", api_client=central_cluster_client) + data["baseUrl"] = base_url + data["sslMMSCAConfigMap"] = multi_cluster_issuer_ca_configmap + create_or_update_configmap( + namespace, + "multi-replica-set-one-config", + data, + api_client=central_cluster_client, + ) + + return create_or_update(resource) + + @mark.e2e_multi_cluster_backup_restore + def test_setup_om_connection( + self, + ops_manager: MongoDBOpsManager, + central_cluster_client: kubernetes.client.ApiClient, + member_cluster_clients: List[MultiClusterClient], + ): + """ + The base_url makes OM accessible from member clusters via a special interconnected dns address. + """ + ops_manager.load() + external_svc_name = ops_manager.external_svc_name() + svc = read_service(ops_manager.namespace, external_svc_name, api_client=central_cluster_client) + # we have no hostName, but the ip is resolvable. + ip = svc.status.load_balancer.ingress[0].ip + + interconnected_field = f"om-backup.{ops_manager.namespace}.interconnected" + + # let's make sure that every client can connect to OM. + for c in member_cluster_clients: + update_coredns_hosts( + host_mappings=[(ip, interconnected_field)], + api_client=c.api_client, + cluster_name=c.cluster_name, + ) + + # let's make sure that the operator can connect to OM via that given address. + update_coredns_hosts( + host_mappings=[(ip, interconnected_field)], + api_client=central_cluster_client, + cluster_name="central-cluster", + ) + + new_address = f"https://{interconnected_field}:8443" + # updating the central url app setting to point at the external address, + # this allows agents in other clusters to communicate correctly with this OM instance. + ops_manager["spec"]["configuration"]["mms.centralUrl"] = new_address + ops_manager.update() + + @mark.e2e_multi_cluster_backup_restore + def test_mongodb_multi_one_running_state(self, mongodb_multi_one: MongoDBMulti): + # we might fail connection in the beginning since we set a custom dns in coredns + mongodb_multi_one.assert_reaches_phase(Phase.Running, ignore_errors=True, timeout=600) + + @skip_if_local + @mark.e2e_multi_cluster_backup_restore + def test_add_test_data(self, mongodb_multi_one_collection): + max_attempts = 100 + while max_attempts > 0: + try: + mongodb_multi_one_collection.insert_one(TEST_DATA) + return + except Exception as e: + print(e) + max_attempts -= 1 + time.sleep(6) + + @mark.e2e_multi_cluster_backup_restore + def test_mdb_backed_up(self, project_one: OMTester): + project_one.wait_until_backup_snapshots_are_ready(expected_count=1) + + @mark.e2e_multi_cluster_backup_restore + def test_change_mdb_data(self, mongodb_multi_one_collection): + now_millis = time_to_millis(datetime.datetime.now()) + print("\nCurrent time (millis): {}".format(now_millis)) + time.sleep(30) + mongodb_multi_one_collection.insert_one({"foo": "bar"}) + + @mark.e2e_multi_cluster_backup_restore + def test_pit_restore(self, project_one: OMTester): + now_millis = time_to_millis(datetime.datetime.now()) + print("\nCurrent time (millis): {}".format(now_millis)) + + pit_datetme = datetime.datetime.now() - datetime.timedelta(seconds=15) + pit_millis = time_to_millis(pit_datetme) + print("Restoring back to the moment 15 seconds ago (millis): {}".format(pit_millis)) + + project_one.create_restore_job_pit(pit_millis) + + @mark.e2e_multi_cluster_backup_restore + def test_data_got_restored(self, mongodb_multi_one_collection): + """The data in the db has been restored to the initial state. Note, that this happens eventually - so + we need to loop for some time (usually takes 20 seconds max). This is different from restoring from a + specific snapshot (see the previous class) where the FINISHED restore job means the data has been restored. + For PIT restores FINISHED just means the job has been created and the agents will perform restore eventually + """ + print("\nWaiting until the db data is restored") + retries = 120 + while retries > 0: + try: + records = list(mongodb_multi_one_collection.find()) + assert records == [TEST_DATA] + return + except AssertionError: + pass + except ServerSelectionTimeoutError: + # The mongodb driver complains with `No replica set members + # match selector "Primary()",` This could be related with DNS + # not being functional, or the database going through a + # re-election process. Let's give it another chance to succeed. + pass + except Exception as e: + # We ignore Exception as there is usually a blip in connection (backup restore + # results in reelection or whatever) + # "Connection reset by peer" or "not master and slaveOk=false" + print("Exception happened while waiting for db data restore: ", e) + # this is definitely the sign of a problem - no need continuing as each connection times out + # after many minutes + if "Connection refused" in str(e): + raise e + retries -= 1 + time.sleep(1) + + print("\nExisting data in MDB: {}".format(list(mongodb_multi_one_collection.find()))) + + raise AssertionError("The data hasn't been restored in 2 minutes!") + + +def time_to_millis(date_time) -> int: + """https://stackoverflow.com/a/11111177/614239""" + epoch = datetime.datetime.utcfromtimestamp(0) + pit_millis = (date_time - epoch).total_seconds() * 1000 + return pit_millis diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_backup_restore_no_mesh.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_backup_restore_no_mesh.py new file mode 100644 index 000000000..8d1f132ca --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_backup_restore_no_mesh.py @@ -0,0 +1,691 @@ +# This test sets up ops manager in a multicluster "no-mesh" environment. +# It tests the back-up functionality with a multi-cluster replica-set when the replica-set is deployed outside of a service-mesh context. + +import datetime +import time +from typing import List, Optional, Tuple + +import kubernetes +import kubernetes.client +from kubernetes import client +from kubetester import ( + create_or_update, + create_or_update_configmap, + create_or_update_secret, + get_default_storage_class, + read_service, + try_load, +) +from kubetester.certs import create_ops_manager_tls_certs +from kubetester.kubetester import KubernetesTester +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.kubetester import skip_if_local +from kubetester.mongodb import MongoDB, Phase +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from kubetester.mongodb_user import MongoDBUser +from kubetester.omtester import OMTester +from kubetester.operator import Operator +from kubetester.opsmanager import MongoDBOpsManager +from pymongo.errors import ServerSelectionTimeoutError +from pytest import fixture, mark +from tests.conftest import update_coredns_hosts + +TEST_DATA = {"name": "John", "address": "Highway 37", "age": 30} +MONGODB_PORT = 30000 + + +HEAD_PATH = "/head/" +OPLOG_RS_NAME = "my-mongodb-oplog" +BLOCKSTORE_RS_NAME = "my-mongodb-blockstore" +USER_PASSWORD = "/qwerty@!#:" + + +@fixture(scope="module") +def ops_manager_certs( + namespace: str, + multi_cluster_issuer: str, + central_cluster_client: kubernetes.client.ApiClient, +): + return create_ops_manager_tls_certs( + multi_cluster_issuer, + namespace, + "om-backup", + secret_name="mdb-om-backup-cert", + # We need the interconnected certificate since we update coreDNS later with that ip -> domain + # because our central cluster is not part of the mesh, but we can access the pods via external IPs. + # Since we are using TLS we need a certificate for a hostname, an IP does not work, hence + # f"om-backup.{namespace}.interconnected" -> IP setup below + additional_domains=["fastdl.mongodb.org", f"om-backup.{namespace}.interconnected"], + api_client=central_cluster_client, + ) + + +def create_project_config_map(om: MongoDBOpsManager, mdb_name, project_name, client, custom_ca): + name = f"{mdb_name}-config" + data = { + "baseUrl": om.om_status().get_url(), + "projectName": project_name, + "sslMMSCAConfigMap": custom_ca, + "orgId": "", + } + + create_or_update_configmap(om.namespace, name, data, client) + + +def new_om_data_store( + mdb: MongoDB, + id: str, + assignment_enabled: bool = True, + user_name: Optional[str] = None, + password: Optional[str] = None, +) -> dict: + return { + "id": id, + "uri": mdb.mongo_uri(user_name=user_name, password=password), + "ssl": mdb.is_tls_enabled(), + "assignmentEnabled": assignment_enabled, + } + + +@fixture(scope="module") +def ops_manager( + namespace: str, + multi_cluster_issuer_ca_configmap: str, + custom_appdb_version: str, + ops_manager_certs: str, + central_cluster_client: kubernetes.client.ApiClient, +) -> MongoDBOpsManager: + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_backup.yaml"), namespace=namespace + ) + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + resource["spec"]["externalConnectivity"] = {"type": "LoadBalancer"} + resource["spec"]["security"] = { + "certsSecretPrefix": "mdb", + "tls": {"ca": multi_cluster_issuer_ca_configmap}, + } + # remove s3 config + del resource["spec"]["backup"]["s3Stores"] + + resource.allow_mdb_rc_versions() + resource.create_admin_secret(api_client=central_cluster_client) + + try_load(resource) + + return resource + + +@fixture(scope="module") +def oplog_replica_set( + ops_manager, + namespace, + custom_mdb_version: str, + central_cluster_client: kubernetes.client.ApiClient, + multi_cluster_issuer_ca_configmap: str, +) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=OPLOG_RS_NAME, + ) + + create_project_config_map( + om=ops_manager, + project_name="development", + mdb_name=OPLOG_RS_NAME, + client=central_cluster_client, + custom_ca=multi_cluster_issuer_ca_configmap, + ) + + resource.configure(ops_manager, "development") + + resource["spec"]["opsManager"]["configMapRef"]["name"] = OPLOG_RS_NAME + "-config" + resource["spec"]["version"] = custom_mdb_version + + resource["spec"]["security"] = {"authentication": {"enabled": True, "modes": ["SCRAM"]}} + + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + yield create_or_update(resource) + + +@fixture(scope="module") +def blockstore_replica_set( + ops_manager, + namespace, + custom_mdb_version: str, + central_cluster_client: kubernetes.client.ApiClient, + multi_cluster_issuer_ca_configmap: str, +) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=BLOCKSTORE_RS_NAME, + ) + + create_project_config_map( + om=ops_manager, + project_name="blockstore", + mdb_name=BLOCKSTORE_RS_NAME, + client=central_cluster_client, + custom_ca=multi_cluster_issuer_ca_configmap, + ) + + resource.configure(ops_manager, "blockstore") + + resource["spec"]["version"] = custom_mdb_version + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + yield create_or_update(resource) + + +@fixture(scope="module") +def blockstore_user( + namespace, + blockstore_replica_set: MongoDB, + central_cluster_client: kubernetes.client.ApiClient, +) -> MongoDBUser: + """Creates a password secret and then the user referencing it""" + resource = MongoDBUser.from_yaml(yaml_fixture("scram-sha-user-backing-db.yaml"), namespace=namespace) + resource["spec"]["mongodbResourceRef"]["name"] = blockstore_replica_set.name + + print(f"\nCreating password for MongoDBUser {resource.name} in secret/{resource.get_secret_name()} ") + create_or_update_secret( + KubernetesTester.get_namespace(), + resource.get_secret_name(), + { + "password": USER_PASSWORD, + }, + api_client=central_cluster_client, + ) + + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + yield create_or_update(resource) + + +@fixture(scope="module") +def oplog_user( + namespace, + oplog_replica_set: MongoDB, + central_cluster_client: kubernetes.client.ApiClient, +) -> MongoDBUser: + """Creates a password secret and then the user referencing it""" + resource = MongoDBUser.from_yaml( + yaml_fixture("scram-sha-user-backing-db.yaml"), + namespace=namespace, + name="mms-user-2", + ) + resource["spec"]["mongodbResourceRef"]["name"] = oplog_replica_set.name + resource["spec"]["passwordSecretKeyRef"]["name"] = "mms-user-2-password" + resource["spec"]["username"] = "mms-user-2" + + print(f"\nCreating password for MongoDBUser {resource.name} in secret/{resource.get_secret_name()} ") + create_or_update_secret( + KubernetesTester.get_namespace(), + resource.get_secret_name(), + { + "password": USER_PASSWORD, + }, + api_client=central_cluster_client, + ) + + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + yield create_or_update(resource) + + +@fixture(scope="module") +def replica_set_external_hosts() -> List[Tuple[str, str]]: + return [ + ("172.18.255.211", "test.kind-e2e-cluster-1.interconnected"), + ( + "172.18.255.211", + "multi-replica-set-one-0-0.kind-e2e-cluster-1.interconnected", + ), + ( + "172.18.255.212", + "multi-replica-set-one-0-1.kind-e2e-cluster-1.interconnected", + ), + ( + "172.18.255.213", + "multi-replica-set-one-0-2.kind-e2e-cluster-1.interconnected", + ), + ("172.18.255.221", "test.kind-e2e-cluster-2.interconnected"), + ( + "172.18.255.221", + "multi-replica-set-one-1-0.kind-e2e-cluster-2.interconnected", + ), + ( + "172.18.255.222", + "multi-replica-set-one-1-1.kind-e2e-cluster-2.interconnected", + ), + ( + "172.18.255.223", + "multi-replica-set-one-1-2.kind-e2e-cluster-2.interconnected", + ), + ("172.18.255.231", "test.kind-e2e-cluster-3.interconnected"), + ( + "172.18.255.231", + "multi-replica-set-one-2-0.kind-e2e-cluster-3.interconnected", + ), + ( + "172.18.255.232", + "multi-replica-set-one-2-1.kind-e2e-cluster-3.interconnected", + ), + ( + "172.18.255.233", + "multi-replica-set-one-2-2.kind-e2e-cluster-3.interconnected", + ), + ] + + +@fixture(scope="module") +def disable_istio( + multi_cluster_operator: Operator, + namespace: str, + member_cluster_clients: List[MultiClusterClient], +): + for mcc in member_cluster_clients: + api = client.CoreV1Api(api_client=mcc.api_client) + labels = {"istio-injection": "disabled"} + ns = api.read_namespace(name=namespace) + ns.metadata.labels.update(labels) + api.replace_namespace(name=namespace, body=ns) + return None + + +@mark.e2e_multi_cluster_backup_restore_no_mesh +def test_update_coredns( + replica_set_external_hosts: List[Tuple[str, str]], cluster_clients: dict[str, kubernetes.client.ApiClient] +): + """ + This test updates the coredns config in the member clusters to allow connecting to the other replica set members + through an external address. + """ + for cluster_name, cluster_api in cluster_clients.items(): + update_coredns_hosts(replica_set_external_hosts, cluster_name, api_client=cluster_api) + + +@mark.e2e_multi_cluster_backup_restore_no_mesh +def test_deploy_operator(multi_cluster_operator: Operator): + multi_cluster_operator.assert_is_running() + + +@mark.e2e_multi_cluster_backup_restore_no_mesh +class TestOpsManagerCreation: + """ + name: Ops Manager successful creation with backup and oplog stores enabled + description: | + Creates an Ops Manager instance with backup enabled. The OM is expected to get to 'Pending' state + eventually as it will wait for oplog db to be created + """ + + def test_create_om( + self, + ops_manager: MongoDBOpsManager, + ): + ops_manager["spec"]["backup"]["headDB"]["storageClass"] = get_default_storage_class() + ops_manager["spec"]["backup"]["members"] = 1 + + create_or_update(ops_manager) + + ops_manager.backup_status().assert_reaches_phase( + Phase.Pending, + msg_regexp="The MongoDB object .+ doesn't exist", + timeout=1800, + ) + + def test_daemon_statefulset( + self, + ops_manager: MongoDBOpsManager, + central_cluster_client: kubernetes.client.ApiClient, + ): + def stateful_set_becomes_ready(): + stateful_set = ops_manager.read_backup_statefulset(central_cluster_client) + return stateful_set.status.ready_replicas == 1 and stateful_set.status.current_replicas == 1 + + KubernetesTester.wait_until(stateful_set_becomes_ready, timeout=300) + + stateful_set = ops_manager.read_backup_statefulset(central_cluster_client) + # pod template has volume mount request + assert (HEAD_PATH, "head") in ( + (mount.mount_path, mount.name) for mount in stateful_set.spec.template.spec.containers[0].volume_mounts + ) + + def test_backup_daemon_services_created( + self, + namespace, + central_cluster_client: kubernetes.client.ApiClient, + ): + """Backup creates two additional services for queryable backup""" + services = client.CoreV1Api(api_client=central_cluster_client).list_namespaced_service(namespace).items + + backup_services = [s for s in services if s.metadata.name.startswith("om-backup")] + + assert len(backup_services) >= 3 + + +@mark.e2e_multi_cluster_backup_restore_no_mesh +class TestBackupDatabasesAdded: + """name: Creates mongodb resources for oplog and blockstore and waits until OM resource gets to + running state""" + + def test_backup_mdbs_created( + self, + oplog_replica_set: MongoDB, + blockstore_replica_set: MongoDB, + ): + """Creates mongodb databases all at once""" + oplog_replica_set.assert_reaches_phase(Phase.Running) + blockstore_replica_set.assert_reaches_phase(Phase.Running) + + def test_oplog_user_created(self, oplog_user: MongoDBUser): + oplog_user.assert_reaches_phase(Phase.Updated) + + def test_om_failed_oplog_no_user_ref(self, ops_manager: MongoDBOpsManager): + """Waits until Backup is in failed state as blockstore doesn't have reference to the user""" + ops_manager.backup_status().assert_reaches_phase( + Phase.Failed, + msg_regexp=".*is configured to use SCRAM-SHA authentication mode, the user " + "must be specified using 'mongodbUserRef'", + ) + + def test_fix_om(self, ops_manager: MongoDBOpsManager, oplog_user: MongoDBUser): + ops_manager.load() + ops_manager["spec"]["backup"]["opLogStores"][0]["mongodbUserRef"] = {"name": oplog_user.name} + ops_manager.update() + + ops_manager.backup_status().assert_reaches_phase( + Phase.Running, + timeout=200, + ignore_errors=True, + ) + + assert ops_manager.backup_status().get_message() is None + + +class TestBackupForMongodb: + @fixture(scope="module") + def base_url( + self, + ops_manager: MongoDBOpsManager, + ) -> str: + """ + The base_url makes OM accessible from member clusters via a special interconnected dns address. + We also use this address for the operator to connect to ops manager, + because the operator and the replica-sets rely on the same project configmap. + """ + interconnected_field = f"https://om-backup.{ops_manager.namespace}.interconnected" + new_address = f"{interconnected_field}:8443" + + return new_address + + @fixture(scope="module") + def project_one( + self, + ops_manager: MongoDBOpsManager, + namespace: str, + central_cluster_client: kubernetes.client.ApiClient, + base_url: str, + ) -> OMTester: + return ops_manager.get_om_tester( + project_name=f"{namespace}-project-one", + api_client=central_cluster_client, + base_url=base_url, + ) + + @fixture(scope="module") + def mongodb_multi_one_collection(self, mongodb_multi_one: MongoDBMulti): + collection = mongodb_multi_one.tester( + port=MONGODB_PORT, + service_names=[ + "multi-replica-set-one-0-0.kind-e2e-cluster-1.interconnected", + "multi-replica-set-one-0-1.kind-e2e-cluster-1.interconnected", + "multi-replica-set-one-1-0.kind-e2e-cluster-2.interconnected", + "multi-replica-set-one-2-0.kind-e2e-cluster-3.interconnected", + "multi-replica-set-one-2-1.kind-e2e-cluster-3.interconnected", + ], + external=True, + ).client["testdb"] + return collection["testcollection"] + + @fixture(scope="module") + def mongodb_multi_one( + self, + ops_manager: MongoDBOpsManager, + multi_cluster_issuer_ca_configmap: str, + central_cluster_client: kubernetes.client.ApiClient, + disable_istio, + namespace: str, + member_cluster_names: List[str], + base_url, + ) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml( + yaml_fixture("mongodb-multi.yaml"), + "multi-replica-set-one", + namespace + # the project configmap should be created in the central cluster. + ).configure(ops_manager, f"{namespace}-project-one", api_client=central_cluster_client) + + resource["spec"]["clusterSpecList"] = [ + {"clusterName": member_cluster_names[0], "members": 2}, + {"clusterName": member_cluster_names[1], "members": 1}, + {"clusterName": member_cluster_names[2], "members": 2}, + ] + + resource["spec"]["externalAccess"] = {} + resource["spec"]["clusterSpecList"][0]["externalAccess"] = { + "externalDomain": "kind-e2e-cluster-1.interconnected", + "externalService": { + "spec": { + "type": "LoadBalancer", + "publishNotReadyAddresses": False, + "ports": [ + { + "name": "mongodb", + "port": MONGODB_PORT, + }, + { + "name": "backup", + "port": MONGODB_PORT + 1, + }, + { + "name": "testing0", + "port": MONGODB_PORT + 2, + }, + ], + } + }, + } + resource["spec"]["clusterSpecList"][1]["externalAccess"] = { + "externalDomain": "kind-e2e-cluster-2.interconnected", + "externalService": { + "spec": { + "type": "LoadBalancer", + "publishNotReadyAddresses": False, + "ports": [ + { + "name": "mongodb", + "port": MONGODB_PORT, + }, + { + "name": "backup", + "port": MONGODB_PORT + 1, + }, + { + "name": "testing1", + "port": MONGODB_PORT + 2, + }, + ], + } + }, + } + resource["spec"]["clusterSpecList"][2]["externalAccess"] = { + "externalDomain": "kind-e2e-cluster-3.interconnected", + "externalService": { + "spec": { + "type": "LoadBalancer", + "publishNotReadyAddresses": False, + "ports": [ + { + "name": "mongodb", + "port": MONGODB_PORT, + }, + { + "name": "backup", + "port": MONGODB_PORT + 1, + }, + { + "name": "testing2", + "port": MONGODB_PORT + 2, + }, + ], + } + }, + } + + # creating a cluster with backup should work with custom ports + resource["spec"].update({"additionalMongodConfig": {"net": {"port": MONGODB_PORT}}}) + + resource.configure_backup(mode="enabled") + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + + data = KubernetesTester.read_configmap( + namespace, "multi-replica-set-one-config", api_client=central_cluster_client + ) + KubernetesTester.delete_configmap(namespace, "multi-replica-set-one-config", api_client=central_cluster_client) + data["baseUrl"] = base_url + data["sslMMSCAConfigMap"] = multi_cluster_issuer_ca_configmap + create_or_update_configmap( + namespace, + "multi-replica-set-one-config", + data, + api_client=central_cluster_client, + ) + + return create_or_update(resource) + + @mark.e2e_multi_cluster_backup_restore_no_mesh + def test_setup_om_connection( + self, + replica_set_external_hosts: List[Tuple[str, str]], + ops_manager: MongoDBOpsManager, + central_cluster_client: kubernetes.client.ApiClient, + member_cluster_clients: List[MultiClusterClient], + ): + """ + test_setup_om_connection makes OM accessible from member clusters via a special interconnected dns address. + """ + ops_manager.load() + external_svc_name = ops_manager.external_svc_name() + svc = read_service(ops_manager.namespace, external_svc_name, api_client=central_cluster_client) + # we have no hostName, but the ip is resolvable. + ip = svc.status.load_balancer.ingress[0].ip + + interconnected_field = f"om-backup.{ops_manager.namespace}.interconnected" + + # let's make sure that every client can connect to OM. + hosts = replica_set_external_hosts[:] + hosts.append((ip, interconnected_field)) + + for c in member_cluster_clients: + update_coredns_hosts( + host_mappings=hosts, + api_client=c.api_client, + cluster_name=c.cluster_name, + ) + + # let's make sure that the operator can connect to OM via that given address. + update_coredns_hosts( + host_mappings=[(ip, interconnected_field)], + api_client=central_cluster_client, + cluster_name="central-cluster", + ) + + new_address = f"https://{interconnected_field}:8443" + # updating the central url app setting to point at the external address, + # this allows agents in other clusters to communicate correctly with this OM instance. + ops_manager["spec"]["configuration"]["mms.centralUrl"] = new_address + ops_manager.update() + + @mark.e2e_multi_cluster_backup_restore_no_mesh + def test_mongodb_multi_one_running_state(self, mongodb_multi_one: MongoDBMulti): + # we might fail connection in the beginning since we set a custom dns in coredns + mongodb_multi_one.assert_reaches_phase(Phase.Running, ignore_errors=True, timeout=600) + + @skip_if_local + @mark.e2e_multi_cluster_backup_restore_no_mesh + def test_add_test_data(self, mongodb_multi_one_collection): + max_attempts = 100 + while max_attempts > 0: + try: + mongodb_multi_one_collection.insert_one(TEST_DATA) + return + except Exception as e: + print(e) + max_attempts -= 1 + time.sleep(6) + + @mark.e2e_multi_cluster_backup_restore_no_mesh + def test_mdb_backed_up(self, project_one: OMTester): + project_one.wait_until_backup_snapshots_are_ready(expected_count=1) + + @mark.e2e_multi_cluster_backup_restore_no_mesh + def test_change_mdb_data(self, mongodb_multi_one_collection): + now_millis = time_to_millis(datetime.datetime.now()) + print("\nCurrent time (millis): {}".format(now_millis)) + time.sleep(30) + mongodb_multi_one_collection.insert_one({"foo": "bar"}) + + @mark.e2e_multi_cluster_backup_restore_no_mesh + def test_pit_restore(self, project_one: OMTester): + now_millis = time_to_millis(datetime.datetime.now()) + print("\nCurrent time (millis): {}".format(now_millis)) + + pit_datetme = datetime.datetime.now() - datetime.timedelta(seconds=15) + pit_millis = time_to_millis(pit_datetme) + print("Restoring back to the moment 15 seconds ago (millis): {}".format(pit_millis)) + + project_one.create_restore_job_pit(pit_millis) + + @mark.e2e_multi_cluster_backup_restore_no_mesh + def test_data_got_restored(self, mongodb_multi_one_collection): + """The data in the db has been restored to the initial state. Note, that this happens eventually - so + we need to loop for some time (usually takes 20 seconds max). This is different from restoring from a + specific snapshot (see the previous class) where the FINISHED restore job means the data has been restored. + For PIT restores FINISHED just means the job has been created and the agents will perform restore eventually + """ + print("\nWaiting until the db data is restored") + retries = 120 + while retries > 0: + try: + records = list(mongodb_multi_one_collection.find()) + assert records == [TEST_DATA] + return + except AssertionError: + pass + except ServerSelectionTimeoutError: + # The mongodb driver complains with `No replica set members + # match selector "Primary()",` This could be related with DNS + # not being functional, or the database going through a + # re-election process. Let's give it another chance to succeed. + pass + except Exception as e: + # We ignore Exception as there is usually a blip in connection (backup restore + # results in reelection or whatever) + # "Connection reset by peer" or "not master and slaveOk=false" + print("Exception happened while waiting for db data restore: ", e) + # this is definitely the sign of a problem - no need continuing as each connection times out + # after many minutes + if "Connection refused" in str(e): + raise e + retries -= 1 + time.sleep(1) + + print("\nExisting data in MDB: {}".format(list(mongodb_multi_one_collection.find()))) + + raise AssertionError("The data hasn't been restored in 2 minutes!") + + +def time_to_millis(date_time) -> int: + """https://stackoverflow.com/a/11111177/614239""" + epoch = datetime.datetime.utcfromtimestamp(0) + pit_millis = (date_time - epoch).total_seconds() * 1000 + return pit_millis diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_cli_recover.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_cli_recover.py new file mode 100644 index 000000000..2d512f994 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_cli_recover.py @@ -0,0 +1,161 @@ +from typing import List, Callable, Dict + +import kubernetes +import pytest + + +from kubetester import create_or_update +from kubetester.certs import create_multi_cluster_mongodb_tls_certs +from kubetester.mongodb import Phase +from kubetester.mongodb_multi import ( + MongoDBMulti, + MultiClusterClient, +) + +from kubetester.operator import Operator +from kubetester.kubetester import ( + fixture as yaml_fixture, +) +from tests.conftest import ( + run_kube_config_creation_tool, + run_multi_cluster_recovery_tool, + MULTI_CLUSTER_OPERATOR_NAME, +) +from tests.multicluster.conftest import cluster_spec_list + +RESOURCE_NAME = "multi-replica-set" +BUNDLE_SECRET_NAME = f"prefix-{RESOURCE_NAME}-cert" + + +@pytest.fixture(scope="module") +def mongodb_multi_unmarshalled( + namespace: str, + multi_cluster_issuer_ca_configmap: str, + central_cluster_client: kubernetes.client.ApiClient, + member_cluster_names: List[str], +) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml( + yaml_fixture("mongodb-multi.yaml"), RESOURCE_NAME, namespace + ) + # ensure certs are created for the members during scale up + resource["spec"]["clusterSpecList"] = cluster_spec_list( + member_cluster_names, [2, 1, 2] + ) + resource["spec"]["security"] = { + "certsSecretPrefix": "prefix", + "tls": { + "ca": multi_cluster_issuer_ca_configmap, + }, + } + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + return resource + + +@pytest.fixture(scope="module") +def server_certs( + multi_cluster_issuer: str, + mongodb_multi_unmarshalled: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], + central_cluster_client: kubernetes.client.ApiClient, +): + return create_multi_cluster_mongodb_tls_certs( + multi_cluster_issuer, + BUNDLE_SECRET_NAME, + member_cluster_clients, + central_cluster_client, + mongodb_multi_unmarshalled, + ) + + +@pytest.fixture(scope="module") +def mongodb_multi( + mongodb_multi_unmarshalled: MongoDBMulti, server_certs: str +) -> MongoDBMulti: + mongodb_multi_unmarshalled["spec"]["clusterSpecList"].pop() + create_or_update(mongodb_multi_unmarshalled) + return mongodb_multi_unmarshalled + + +@pytest.mark.e2e_multi_cluster_recover +def test_deploy_operator( + install_multi_cluster_operator_set_members_fn: Callable[[List[str]], Operator], + member_cluster_names: List[str], + namespace: str, +): + run_kube_config_creation_tool( + member_cluster_names[:-1], namespace, namespace, member_cluster_names + ) + # deploy the operator without the final cluster + operator = install_multi_cluster_operator_set_members_fn(member_cluster_names[:-1]) + operator.assert_is_running() + + +@pytest.mark.e2e_multi_cluster_recover +def test_create_mongodb_multi(mongodb_multi: MongoDBMulti): + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=600) + + +@pytest.mark.e2e_multi_cluster_recover +def test_recover_operator_add_cluster( + member_cluster_names: List[str], + namespace: str, + central_cluster_client: kubernetes.client.ApiClient, +): + return_code = run_multi_cluster_recovery_tool( + member_cluster_names, namespace, namespace + ) + assert return_code == 0 + operator = Operator( + name=MULTI_CLUSTER_OPERATOR_NAME, + namespace=namespace, + api_client=central_cluster_client, + ) + operator._wait_for_operator_ready() + operator.assert_is_running() + + +@pytest.mark.e2e_multi_cluster_recover +def test_mongodb_multi_recovers_adding_cluster( + mongodb_multi: MongoDBMulti, member_cluster_names: List[str] +): + mongodb_multi.load() + + mongodb_multi["spec"]["clusterSpecList"].append( + {"clusterName": member_cluster_names[-1], "members": 2} + ) + mongodb_multi.update() + mongodb_multi.assert_abandons_phase(Phase.Running, timeout=50) + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=600) + + +@pytest.mark.e2e_multi_cluster_recover +def test_recover_operator_remove_cluster( + member_cluster_names: List[str], + namespace: str, + central_cluster_client: kubernetes.client.ApiClient, +): + return_code = run_multi_cluster_recovery_tool( + member_cluster_names[1:], namespace, namespace + ) + assert return_code == 0 + operator = Operator( + name=MULTI_CLUSTER_OPERATOR_NAME, + namespace=namespace, + api_client=central_cluster_client, + ) + operator._wait_for_operator_ready() + operator.assert_is_running() + + +@pytest.mark.e2e_multi_cluster_recover +def test_mongodb_multi_recovers_removing_cluster( + mongodb_multi: MongoDBMulti, member_cluster_names: List[str] +): + last_transition_time = mongodb_multi.get_status_last_transition_time() + mongodb_multi.load() + + mongodb_multi.assert_state_transition_happens(last_transition_time) + + mongodb_multi["spec"]["clusterSpecList"].pop(0) + mongodb_multi.update() + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=800) diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_clusterwide.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_clusterwide.py new file mode 100644 index 000000000..fff666d55 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_clusterwide.py @@ -0,0 +1,287 @@ +from typing import Dict, List +import time +from pytest import mark, fixture +from kubetester.kubetester import create_testing_namespace +import kubernetes +from kubernetes import client +from kubetester.mongodb import Phase +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from kubetester.operator import Operator +from kubetester.kubetester import fixture as yaml_fixture, skip_if_local +from tests.conftest import ( + run_kube_config_creation_tool, + _install_multi_cluster_operator, + MULTI_CLUSTER_OPERATOR_NAME, +) +import os +from . import prepare_multi_cluster_namespaces +from kubetester.kubetester import KubernetesTester +from kubetester import ( + create_secret, + read_secret, + create_or_update_secret, + create_or_update_configmap, + create_or_update, +) +from .conftest import cluster_spec_list + + +@fixture(scope="module") +def mdba_ns(namespace: str): + return "{}-mdb-ns-a".format(namespace) + + +@fixture(scope="module") +def mdbb_ns(namespace: str): + return "{}-mdb-ns-b".format(namespace) + + +@fixture(scope="module") +def unmanaged_mdb_ns(namespace: str): + return "{}-mdb-ns-c".format(namespace) + + +def create_namespace( + central_cluster_client: kubernetes.client.ApiClient, + member_cluster_clients: List[MultiClusterClient], + task_id: str, + namespace: str, + image_pull_secret_name: str, + image_pull_secret_data: Dict[str, str], +) -> str: + for client in member_cluster_clients: + create_testing_namespace(task_id, namespace, client.api_client, True) + create_or_update_secret( + namespace, + image_pull_secret_name, + image_pull_secret_data, + type="kubernetes.io/dockerconfigjson", + api_client=client.api_client, + ) + + create_testing_namespace(task_id, namespace, central_cluster_client) + create_or_update_secret( + namespace, + image_pull_secret_name, + image_pull_secret_data, + type="kubernetes.io/dockerconfigjson", + api_client=client.api_client, + ) + + return namespace + + +@fixture(scope="module") +def mongodb_multi_a( + central_cluster_client: kubernetes.client.ApiClient, + mdba_ns: str, + member_cluster_names: List[str], +) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml( + yaml_fixture("mongodb-multi.yaml"), "multi-replica-set", mdba_ns + ) + + resource["spec"]["clusterSpecList"] = cluster_spec_list( + member_cluster_names, [2, 1, 2] + ) + + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + create_or_update(resource) + return resource + + +@fixture(scope="module") +def mongodb_multi_b( + central_cluster_client: kubernetes.client.ApiClient, + mdbb_ns: str, + member_cluster_names: List[str], +) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml( + yaml_fixture("mongodb-multi.yaml"), "multi-replica-set", mdbb_ns + ) + resource["spec"]["clusterSpecList"] = cluster_spec_list( + member_cluster_names, [2, 1, 2] + ) + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + create_or_update(resource) + return resource + + +@fixture(scope="module") +def unmanaged_mongodb_multi( + central_cluster_client: kubernetes.client.ApiClient, + unmanaged_mdb_ns: str, + member_cluster_names: List[str], +) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml( + yaml_fixture("mongodb-multi.yaml"), "multi-replica-set", unmanaged_mdb_ns + ) + + resource["spec"]["clusterSpecList"] = cluster_spec_list( + member_cluster_names, [2, 1, 2] + ) + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + create_or_update(resource) + return resource + + +@fixture(scope="module") +def install_operator( + namespace: str, + central_cluster_name: str, + multi_cluster_operator_installation_config: Dict[str, str], + central_cluster_client: client.ApiClient, + member_cluster_clients: List[kubernetes.client.ApiClient], + cluster_clients: Dict[str, kubernetes.client.ApiClient], + member_cluster_names: List[str], + mdba_ns: str, + mdbb_ns: str, +) -> Operator: + print(f"Installing operator in context: {central_cluster_name}") + os.environ["HELM_KUBECONTEXT"] = central_cluster_name + member_cluster_namespaces = mdba_ns + "," + mdbb_ns + run_kube_config_creation_tool( + member_cluster_names, namespace, namespace, member_cluster_names, True + ) + + return _install_multi_cluster_operator( + namespace, + multi_cluster_operator_installation_config, + central_cluster_client, + member_cluster_clients, + { + "operator.name": MULTI_CLUSTER_OPERATOR_NAME, + "operator.createOperatorServiceAccount": "false", + "operator.watchNamespace": member_cluster_namespaces, + }, + central_cluster_name, + ) + + +@mark.e2e_multi_cluster_clusterwide +def test_deploy_operator(multi_cluster_operator_clustermode: Operator): + multi_cluster_operator_clustermode.assert_is_running() + + +@mark.e2e_multi_cluster_specific_namespaces +def test_create_namespaces( + namespace: str, + mdba_ns: str, + mdbb_ns: str, + unmanaged_mdb_ns: str, + central_cluster_client: kubernetes.client.ApiClient, + member_cluster_clients: List[MultiClusterClient], + evergreen_task_id: str, + multi_cluster_operator_installation_config: Dict[str, str], +): + image_pull_secret_name = multi_cluster_operator_installation_config[ + "registry.imagePullSecrets" + ] + image_pull_secret_data = read_secret(namespace, image_pull_secret_name) + + create_namespace( + central_cluster_client, + member_cluster_clients, + evergreen_task_id, + mdba_ns, + image_pull_secret_name, + image_pull_secret_data, + ) + + create_namespace( + central_cluster_client, + member_cluster_clients, + evergreen_task_id, + mdbb_ns, + image_pull_secret_name, + image_pull_secret_data, + ) + + create_namespace( + central_cluster_client, + member_cluster_clients, + evergreen_task_id, + unmanaged_mdb_ns, + image_pull_secret_name, + image_pull_secret_data, + ) + + +@mark.e2e_multi_cluster_specific_namespaces +def test_deploy_operator(install_operator: Operator): + install_operator.assert_is_running() + + +@mark.e2e_multi_cluster_specific_namespaces +def test_prepare_namespace( + multi_cluster_operator_installation_config: Dict[str, str], + member_cluster_clients: List[MultiClusterClient], + central_cluster_name: str, + mdba_ns: str, + mdbb_ns: str, +): + prepare_multi_cluster_namespaces( + mdba_ns, + multi_cluster_operator_installation_config, + member_cluster_clients, + central_cluster_name, + ) + + prepare_multi_cluster_namespaces( + mdbb_ns, + multi_cluster_operator_installation_config, + member_cluster_clients, + central_cluster_name, + ) + + +@mark.e2e_multi_cluster_specific_namespaces +def test_copy_configmap_and_secret_across_ns( + namespace: str, + central_cluster_client: client.ApiClient, + multi_cluster_operator_installation_config: Dict[str, str], + mdba_ns: str, + mdbb_ns: str, +): + data = KubernetesTester.read_configmap( + namespace, "my-project", api_client=central_cluster_client + ) + data["projectName"] = mdba_ns + create_or_update_configmap( + mdba_ns, "my-project", data, api_client=central_cluster_client + ) + + data["projectName"] = mdbb_ns + create_or_update_configmap( + mdbb_ns, "my-project", data, api_client=central_cluster_client + ) + + data = read_secret(namespace, "my-credentials", api_client=central_cluster_client) + create_or_update_secret( + mdba_ns, "my-credentials", data, api_client=central_cluster_client + ) + create_or_update_secret( + mdbb_ns, "my-credentials", data, api_client=central_cluster_client + ) + + +@mark.e2e_multi_cluster_specific_namespaces +def test_create_mongodb_multi_nsa(mongodb_multi_a: MongoDBMulti): + mongodb_multi_a.assert_reaches_phase(Phase.Running, timeout=800) + + +@mark.e2e_multi_cluster_specific_namespaces +def test_create_mongodb_multi_nsb(mongodb_multi_b: MongoDBMulti): + mongodb_multi_b.assert_reaches_phase(Phase.Running, timeout=800) + + +@mark.e2e_multi_cluster_specific_namespaces +def test_create_mongodb_multi_unmanaged(unmanaged_mongodb_multi: MongoDBMulti): + """ + For an unmanaged resource, the status should not be updated! + """ + for i in range(10): + time.sleep(5) + + unmanaged_mongodb_multi.reload() + assert "status" not in unmanaged_mongodb_multi diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_dr_connect.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_dr_connect.py new file mode 100644 index 000000000..b96499b91 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_dr_connect.py @@ -0,0 +1,111 @@ +import subprocess +from typing import Dict, List +import time +import kubernetes +import pytest + +from kubetester.mongodb import Phase +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from kubetester.operator import Operator +from kubetester.kubetester import fixture as yaml_fixture, skip_if_local + +TEST_DATA = {"name": "John", "address": "Highway 37", "age": 30} +CLUSTER_TO_DELETE = "member-3a" + + +# this test is intended to run locally, using telepresence. Make sure to configure the cluster_context to api-server mapping +# in the "cluster_host_mapping" fixture before running it. It is intented to be run locally with the command: make e2e-telepresence test=e2e_multi_cluster_dr local=true +@pytest.fixture(scope="module") +def mongodb_multi( + central_cluster_client: kubernetes.client.ApiClient, namespace: str +) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml( + yaml_fixture("mongodb-multi-dr.yaml"), "multi-replica-set", namespace + ) + + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + # return resource.load() + return resource.create() + + +@pytest.fixture(scope="module") +def mongodb_multi_collection(mongodb_multi: MongoDBMulti): + collection = mongodb_multi.tester().client["testdb"] + return collection["testcollection"] + + +@pytest.mark.e2e_multi_cluster_dr +def test_create_kube_config_file(cluster_clients: Dict): + clients = cluster_clients + assert len(clients) == 4 + + +@pytest.mark.e2e_multi_cluster_dr +def test_deploy_operator(multi_cluster_operator: Operator): + multi_cluster_operator.assert_is_running() + + +@pytest.mark.e2e_multi_cluster_dr +def test_create_mongodb_multi(mongodb_multi: MongoDBMulti): + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=600) + + +@pytest.mark.e2e_multi_cluster_dr +def test_replica_set_is_reachable(mongodb_multi: MongoDBMulti): + tester = mongodb_multi.tester() + tester.assert_connectivity() + + +@pytest.mark.e2e_multi_cluster_dr +def test_add_test_data(mongodb_multi_collection): + # TODO: remove this retry mechanism, for some reason the resource exits the running state and then + # enters it later. The subsequent test fails because the resource is not actually + max_attempts = 100 + while max_attempts > 0: + try: + mongodb_multi_collection.insert_one(TEST_DATA) + return + except Exception as e: + print(e) + max_attempts -= 1 + time.sleep(6) + + +@pytest.mark.e2e_multi_cluster_dr +def test_delete_member_3_cluster(): + # delete 3rd cluster with gcloud command + # gcloud container clusters delete member-3a --zone us-west1-a + subprocess.call( + [ + "gcloud", + "container", + "clusters", + "delete", + CLUSTER_TO_DELETE, + "--zone", + "us-west1-a", + "--quiet", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + +@pytest.mark.e2e_multi_cluster_dr +def test_replica_set_is_reachable_after_deletetion(mongodb_multi: MongoDBMulti): + tester = mongodb_multi.tester() + tester.assert_connectivity() + + +@pytest.mark.e2e_multi_cluster_dr +def test_add_test_data_after_deletion(mongodb_multi_collection, capsys): + max_attempts = 100 + while max_attempts > 0: + try: + mongodb_multi_collection.insert_one(TEST_DATA.copy()) + return + except Exception as e: + with capsys.disabled(): + print(e) + max_attempts -= 1 + time.sleep(6) diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_enable_tls.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_enable_tls.py new file mode 100644 index 000000000..e673d43aa --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_enable_tls.py @@ -0,0 +1,101 @@ +from pytest import mark, fixture +from typing import List +from kubetester import read_secret +from kubetester.certs import create_multi_cluster_mongodb_tls_certs +import kubernetes +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from kubetester.kubetester import skip_if_local +from kubetester.mongotester import with_tls +from kubetester.operator import Operator +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.mongodb import Phase +from kubetester.mongodb_user import MongoDBUser +from kubetester import create_secret +from tests.multicluster.conftest import cluster_spec_list + +CERT_SECRET_PREFIX = "clustercert" +MDB_RESOURCE = "multi-cluster-replica-set" +BUNDLE_SECRET_NAME = f"{CERT_SECRET_PREFIX}-{MDB_RESOURCE}-cert" +BUNDLE_PEM_SECRET_NAME = f"{CERT_SECRET_PREFIX}-{MDB_RESOURCE}-cert-pem" +USER_NAME = "my-user-1" +PASSWORD_SECRET_NAME = "mms-user-1-password" +USER_PASSWORD = "my-password" + + +@fixture(scope="module") +def mongodb_multi_unmarshalled( + namespace: str, member_cluster_names, custom_mdb_version: str +) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml( + yaml_fixture("mongodb-multi.yaml"), MDB_RESOURCE, namespace + ) + resource.set_version(custom_mdb_version) + resource["spec"]["clusterSpecList"] = cluster_spec_list( + member_cluster_names, [2, 1, 2] + ) + return resource + + +@fixture(scope="module") +def server_certs( + multi_cluster_issuer: str, + mongodb_multi_unmarshalled: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], + central_cluster_client: kubernetes.client.ApiClient, +): + + return create_multi_cluster_mongodb_tls_certs( + multi_cluster_issuer, + BUNDLE_SECRET_NAME, + member_cluster_clients, + central_cluster_client, + mongodb_multi_unmarshalled, + ) + + +@fixture(scope="module") +def mongodb_multi( + central_cluster_client: kubernetes.client.ApiClient, + mongodb_multi_unmarshalled: MongoDBMulti, +) -> MongoDBMulti: + + resource = mongodb_multi_unmarshalled + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + return resource.create() + + +@mark.e2e_multi_cluster_enable_tls +def test_deploy_operator(multi_cluster_operator: Operator): + multi_cluster_operator.assert_is_running() + + +@mark.e2e_multi_cluster_enable_tls +def test_create_mongodb_multi(mongodb_multi: MongoDBMulti, namespace: str): + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=1200) + + +@mark.e2e_multi_cluster_enable_tls +def test_enabled_tls_mongodb_multi( + mongodb_multi: MongoDBMulti, + namespace: str, + server_certs: str, + multi_cluster_issuer_ca_configmap: str, + member_cluster_clients: List[MultiClusterClient], +): + mongodb_multi.load() + mongodb_multi["spec"]["security"] = { + "certsSecretPrefix": CERT_SECRET_PREFIX, + "tls": { + "ca": multi_cluster_issuer_ca_configmap, + }, + } + mongodb_multi.update() + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=1300) + + # assert the presence of the generated pem certificates in each member cluster + for client in member_cluster_clients: + read_secret( + namespace=namespace, + name=BUNDLE_PEM_SECRET_NAME, + api_client=client.api_client, + ) diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_ldap.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_ldap.py new file mode 100644 index 000000000..f3c20e3f1 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_ldap.py @@ -0,0 +1,262 @@ +import os +from pytest import mark, fixture +from typing import List + +import kubernetes +from kubetester.automation_config_tester import AutomationConfigTester +from kubetester import get_pod_when_ready, create_secret, create_or_update +from kubetester.certs import create_multi_cluster_mongodb_tls_certs +from kubetester.ldap import ( + OpenLDAP, + LDAPUser, + LDAP_AUTHENTICATION_MECHANISM, +) +from kubetester.helm import helm_install +from kubetester.mongodb import Phase +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from kubetester.mongodb_user import MongoDBUser, generic_user, Role +from kubetester.operator import Operator +from kubetester.kubetester import KubernetesTester, fixture as yaml_fixture +from tests.multicluster.conftest import cluster_spec_list +from tests.opsmanager.conftest import ensure_ent_version + +CERT_SECRET_PREFIX = "clustercert" +MDB_RESOURCE = "multi-replica-set-ldap" +BUNDLE_SECRET_NAME = f"{CERT_SECRET_PREFIX}-{MDB_RESOURCE}-cert" +USER_NAME = "mms-user-1" +PASSWORD = "my-password" +LDAP_NAME = "openldap" + + +@fixture(scope="module") +def mongodb_multi_unmarshalled( + namespace: str, + member_cluster_names, + custom_mdb_version: str, +) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml( + yaml_fixture("mongodb-multi.yaml"), MDB_RESOURCE, namespace + ) + resource.set_version(ensure_ent_version(custom_mdb_version)) + + # Setting the initial clusterSpecList to more members than we need to generate + # the certificates for all the members once the RS is scaled up. + resource["spec"]["clusterSpecList"] = cluster_spec_list( + member_cluster_names, [2, 1, 2] + ) + + return resource + + +@fixture(scope="module") +def server_certs( + multi_cluster_issuer: str, + mongodb_multi_unmarshalled: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], + central_cluster_client: kubernetes.client.ApiClient, +): + return create_multi_cluster_mongodb_tls_certs( + multi_cluster_issuer, + BUNDLE_SECRET_NAME, + member_cluster_clients, + central_cluster_client, + mongodb_multi_unmarshalled, + ) + + +@fixture(scope="module") +def mongodb_multi( + mongodb_multi_unmarshalled: MongoDBMulti, + central_cluster_client: kubernetes.client.ApiClient, + member_cluster_names, + member_cluster_clients: List[MultiClusterClient], + namespace: str, + multi_cluster_issuer_ca_configmap: str, + server_certs: str, + multicluster_openldap_tls: OpenLDAP, + ldap_mongodb_agent_user: LDAPUser, + issuer_ca_configmap: str, +) -> MongoDBMulti: + resource = mongodb_multi_unmarshalled + + secret_name = "bind-query-password" + create_secret( + namespace, + secret_name, + {"password": multicluster_openldap_tls.admin_password}, + api_client=central_cluster_client, + ) + ac_secret_name = "automation-config-password" + create_secret( + namespace, + ac_secret_name, + {"automationConfigPassword": ldap_mongodb_agent_user.password}, + api_client=central_cluster_client, + ) + resource["spec"]["clusterSpecList"] = cluster_spec_list( + member_cluster_names, [1, 1, 1] + ) + + resource["spec"]["security"] = { + "certsSecretPrefix": CERT_SECRET_PREFIX, + "tls": { + "enabled": True, + "ca": multi_cluster_issuer_ca_configmap, + }, + "authentication": { + "enabled": True, + "modes": ["LDAP"], + "ldap": { + "servers": [multicluster_openldap_tls.servers], + "bindQueryUser": "cn=admin,dc=example,dc=org", + "bindQueryPasswordSecretRef": {"name": secret_name}, + "transportSecurity": "tls", + "validateLDAPServerConfig": True, + "caConfigMapRef": {"name": issuer_ca_configmap, "key": "ca-pem"}, + "userToDNMapping": '[{match: "(.+)",substitution: "uid={0},ou=groups,dc=example,dc=org"}]', + }, + "agents": { + "mode": "LDAP", + "automationPasswordSecretRef": { + "name": ac_secret_name, + "key": "automationConfigPassword", + }, + "automationUserName": ldap_mongodb_agent_user.uid, + "automationLdapGroupDN": "cn=agents,ou=groups,dc=example,dc=org", + }, + }, + } + resource["spec"]["additionalMongodConfig"] = {"net": {"ssl": {"mode": "preferSSL"}}} + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + + create_or_update(resource) + return resource + + +@fixture(scope="module") +def user_ldap( + mongodb_multi: MongoDBMulti, + namespace: str, + ldap_mongodb_user: LDAPUser, + central_cluster_client: kubernetes.client.ApiClient, +) -> MongoDBUser: + mongodb_user = ldap_mongodb_user + user = generic_user( + namespace, + username=mongodb_user.uid, + db="$external", + password=mongodb_user.password, + mongodb_resource=mongodb_multi, + ) + user.add_roles( + [ + Role(db="admin", role="clusterAdmin"), + Role(db="admin", role="readWriteAnyDatabase"), + Role(db="admin", role="dbAdminAnyDatabase"), + ] + ) + user.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + create_or_update(user) + return user + + +@mark.e2e_multi_cluster_with_ldap +def test_deploy_operator(multi_cluster_operator: Operator): + multi_cluster_operator.assert_is_running() + + +@mark.e2e_multi_cluster_with_ldap +def test_create_mongodb_multi_with_ldap(mongodb_multi: MongoDBMulti): + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=800) + + +@mark.e2e_multi_cluster_with_ldap +def test_create_ldap_user(mongodb_multi: MongoDBMulti, user_ldap: MongoDBUser): + user_ldap.assert_reaches_phase(Phase.Updated) + ac = AutomationConfigTester(KubernetesTester.get_automation_config()) + ac.assert_authentication_mechanism_enabled( + LDAP_AUTHENTICATION_MECHANISM, active_auth_mechanism=True + ) + ac.assert_expected_users(1) + + +@mark.e2e_multi_cluster_with_ldap +def test_ldap_user_created_and_can_authenticate( + mongodb_multi: MongoDBMulti, user_ldap: MongoDBUser, ca_path: str +): + tester = mongodb_multi.tester() + tester.assert_ldap_authentication( + username=user_ldap["spec"]["username"], + password=user_ldap.password, + ssl_ca_certs=ca_path, + attempts=10, + ) + + +@mark.e2e_multi_cluster_with_ldap +def test_ops_manager_state_correctly_updated( + mongodb_multi: MongoDBMulti, user_ldap: MongoDBUser +): + expected_roles = { + ("admin", "clusterAdmin"), + ("admin", "readWriteAnyDatabase"), + ("admin", "dbAdminAnyDatabase"), + } + ac = AutomationConfigTester(KubernetesTester.get_automation_config()) + ac.assert_expected_users(1) + ac.assert_has_user(user_ldap["spec"]["username"]) + ac.assert_user_has_roles(user_ldap["spec"]["username"], expected_roles) + ac.assert_authentication_mechanism_enabled("PLAIN", active_auth_mechanism=True) + ac.assert_authentication_enabled(expected_num_deployment_auth_mechanisms=1) + + +@mark.e2e_multi_cluster_with_ldap +def test_deployment_is_reachable_with_ldap_agent(mongodb_multi: MongoDBMulti): + tester = mongodb_multi.tester() + tester.assert_deployment_reachable(attempts=10) + + +@mark.e2e_multi_cluster_with_ldap +def test_scale_mongodb_multi(mongodb_multi: MongoDBMulti, member_cluster_names): + mongodb_multi.reload() + mongodb_multi["spec"]["clusterSpecList"] = cluster_spec_list( + member_cluster_names, [2, 1, 2] + ) + mongodb_multi.update() + mongodb_multi.assert_abandons_phase(Phase.Running) + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=800) + + +@mark.e2e_multi_cluster_with_ldap +def test_new_ldap_user_can_authenticate_after_scaling( + mongodb_multi: MongoDBMulti, user_ldap: MongoDBUser, ca_path: str +): + tester = mongodb_multi.tester() + tester.assert_ldap_authentication( + username=user_ldap["spec"]["username"], + password=user_ldap.password, + ssl_ca_certs=ca_path, + attempts=10, + ) + + +@mark.e2e_multi_cluster_with_ldap +def test_disable_agent_auth(mongodb_multi: MongoDBMulti): + mongodb_multi.reload() + mongodb_multi["spec"]["security"]["authentication"]["enabled"] = False + mongodb_multi["spec"]["security"]["authentication"]["agents"]["enabled"] = False + mongodb_multi.update() + mongodb_multi.assert_abandons_phase(Phase.Running) + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=1200) + + +@mark.e2e_multi_cluster_with_ldap +def test_mongodb_multi_connectivity_with_no_auth(mongodb_multi: MongoDBMulti): + tester = mongodb_multi.tester() + tester.assert_connectivity() + + +@mark.e2e_multi_cluster_with_ldap +def test_deployment_is_reachable_with_no_auth(mongodb_multi: MongoDBMulti): + tester = mongodb_multi.tester() + tester.assert_deployment_reachable(attempts=10) diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_ldap_custom_roles.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_ldap_custom_roles.py new file mode 100644 index 000000000..05ed1d9db --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_ldap_custom_roles.py @@ -0,0 +1,242 @@ +import os +from pytest import mark, fixture +from typing import List + +import kubernetes +from kubetester.automation_config_tester import AutomationConfigTester +from kubetester import get_pod_when_ready, create_secret, create_or_update +from kubetester.certs import create_multi_cluster_mongodb_tls_certs +from kubetester.ldap import ( + OpenLDAP, + LDAPUser, + LDAP_AUTHENTICATION_MECHANISM, +) +from kubetester.helm import helm_install +from kubetester.mongodb import Phase +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from kubetester.mongodb_user import MongoDBUser, generic_user, Role +from kubetester.operator import Operator +from kubetester.kubetester import KubernetesTester, fixture as yaml_fixture +from tests.multicluster.conftest import cluster_spec_list + +CERT_SECRET_PREFIX = "clustercert" +MDB_RESOURCE = "multi-replica-set-ldap" +BUNDLE_SECRET_NAME = f"{CERT_SECRET_PREFIX}-{MDB_RESOURCE}-cert" +USER_NAME = "mms-user-1" +PASSWORD = "my-password" +LDAP_NAME = "openldap" + + +@fixture(scope="module") +def mongodb_multi_unmarshalled( + namespace: str, + member_cluster_names, +) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml( + yaml_fixture("mongodb-multi.yaml"), MDB_RESOURCE, namespace + ) + + resource["spec"]["clusterSpecList"] = cluster_spec_list( + member_cluster_names, [2, 1, 2] + ) + + return resource + + +@fixture(scope="module") +def server_certs( + multi_cluster_issuer: str, + mongodb_multi_unmarshalled: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], + central_cluster_client: kubernetes.client.ApiClient, +): + return create_multi_cluster_mongodb_tls_certs( + multi_cluster_issuer, + BUNDLE_SECRET_NAME, + member_cluster_clients, + central_cluster_client, + mongodb_multi_unmarshalled, + ) + + +@fixture(scope="module") +def mongodb_multi( + mongodb_multi_unmarshalled: MongoDBMulti, + central_cluster_client: kubernetes.client.ApiClient, + member_cluster_clients: List[MultiClusterClient], + namespace: str, + multi_cluster_issuer_ca_configmap: str, + server_certs: str, + multicluster_openldap_tls: OpenLDAP, + ldap_mongodb_agent_user: LDAPUser, + issuer_ca_configmap: str, +) -> MongoDBMulti: + resource = mongodb_multi_unmarshalled + secret_name = "bind-query-password" + create_secret( + namespace, + secret_name, + {"password": multicluster_openldap_tls.admin_password}, + api_client=central_cluster_client, + ) + ac_secret_name = "automation-config-password" + create_secret( + namespace, + ac_secret_name, + {"automationConfigPassword": ldap_mongodb_agent_user.password}, + api_client=central_cluster_client, + ) + + resource["spec"]["security"] = { + "certsSecretPrefix": CERT_SECRET_PREFIX, + "tls": { + "enabled": True, + "ca": multi_cluster_issuer_ca_configmap, + }, + "authentication": { + "enabled": True, + "modes": ["LDAP", "SCRAM"], + "ldap": { + "servers": [multicluster_openldap_tls.servers], + "bindQueryUser": "cn=admin,dc=example,dc=org", + "bindQueryPasswordSecretRef": {"name": secret_name}, + "transportSecurity": "tls", + "validateLDAPServerConfig": True, + "caConfigMapRef": {"name": issuer_ca_configmap, "key": "ca-pem"}, + "userToDNMapping": '[{match: "(.+)",substitution: "uid={0},ou=groups,dc=example,dc=org"}]', + "authzQueryTemplate": "{USER}?memberOf?base", + }, + "agents": { + "mode": "SCRAM", + }, + }, + "roles": [ + { + "role": "cn=users,ou=groups,dc=example,dc=org", + "db": "admin", + "privileges": [ + { + "actions": ["insert"], + "resource": {"collection": "foo", "db": "foo"}, + }, + { + "actions": ["insert", "find"], + "resource": {"collection": "", "db": "admin"}, + }, + ], + }, + ], + } + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + + create_or_update(resource) + return resource + + +@fixture(scope="module") +def user_ldap( + mongodb_multi: MongoDBMulti, + namespace: str, + ldap_mongodb_user: LDAPUser, + central_cluster_client: kubernetes.client.ApiClient, +) -> MongoDBUser: + mongodb_user = ldap_mongodb_user + user = generic_user( + namespace, + username=mongodb_user.uid, + db="$external", + password=mongodb_user.password, + mongodb_resource=mongodb_multi, + ) + user.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + create_or_update(user) + return user + + +@mark.e2e_multi_cluster_with_ldap_custom_roles +def test_deploy_operator(multi_cluster_operator: Operator): + multi_cluster_operator.assert_is_running() + + +@mark.e2e_multi_cluster_with_ldap_custom_roles +def test_create_mongodb_multi_with_ldap(mongodb_multi: MongoDBMulti): + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=800) + + +@mark.e2e_multi_cluster_with_ldap_custom_roles +def test_create_ldap_user(mongodb_multi: MongoDBMulti, user_ldap: MongoDBUser): + user_ldap.assert_reaches_phase(Phase.Updated) + ac = AutomationConfigTester(KubernetesTester.get_automation_config()) + ac.assert_authentication_mechanism_enabled( + LDAP_AUTHENTICATION_MECHANISM, active_auth_mechanism=False + ) + ac.assert_expected_users(1) + + +@mark.e2e_multi_cluster_with_ldap_custom_roles +def test_ldap_user_can_write_to_database( + mongodb_multi: MongoDBMulti, user_ldap: MongoDBUser, ca_path: str +): + tester = mongodb_multi.tester() + tester.assert_ldap_authentication( + username=user_ldap["spec"]["username"], + password=user_ldap.password, + ssl_ca_certs=ca_path, + db="foo", + collection="foo", + attempts=10, + ) + + +@mark.e2e_multi_cluster_with_ldap_custom_roles +@mark.xfail( + reason="The user should not be able to write to a database/collection it is not authorized to write on" +) +def test_ldap_user_can_write_to_other_collection( + mongodb_multi: MongoDBMulti, user_ldap: MongoDBUser, ca_path: str +): + tester = mongodb_multi.tester() + tester.assert_ldap_authentication( + username=user_ldap["spec"]["username"], + password=user_ldap.password, + ssl_ca_certs=ca_path, + db="foo", + collection="foo2", + attempts=10, + ) + + +@mark.e2e_multi_cluster_with_ldap_custom_roles +@mark.xfail( + reason="The user should not be able to write to a database/collection it is not authorized to write on" +) +def test_ldap_user_can_write_to_other_database( + mongodb_multi: MongoDBMulti, user_ldap: MongoDBUser, ca_path: str +): + tester = mongodb_multi.tester() + tester.assert_ldap_authentication( + username=user_ldap["spec"]["username"], + password=user_ldap.password, + ssl_ca_certs=ca_path, + db="foo2", + collection="foo", + attempts=10, + ) + + +@mark.e2e_multi_cluster_with_ldap_custom_roles +def test_automation_config_has_roles(mongodb_multi: MongoDBMulti): + tester = mongodb_multi.get_automation_config_tester() + role = { + "role": "cn=users,ou=groups,dc=example,dc=org", + "db": "admin", + "privileges": [ + {"actions": ["insert"], "resource": {"collection": "foo", "db": "foo"}}, + { + "actions": ["insert", "find"], + "resource": {"collection": "", "db": "admin"}, + }, + ], + "authenticationRestrictions": [], + } + tester.assert_expected_role(role_index=0, expected_value=role) diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_recover_clusterwide.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_recover_clusterwide.py new file mode 100644 index 000000000..d1aeda1fc --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_recover_clusterwide.py @@ -0,0 +1,332 @@ +import os +from typing import Dict, List + +import kubernetes +from kubeobject import CustomObject +from kubernetes import client +from kubetester import ( + create_secret, + delete_cluster_role, + delete_cluster_role_binding, + read_secret, + random_k8s_name, + create_or_update_secret, + create_or_update, + create_or_update_configmap, +) +from kubetester.kubetester import KubernetesTester, create_testing_namespace +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.mongodb import Phase +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from kubetester.operator import Operator +from pytest import fixture, mark +from tests.conftest import ( + MULTI_CLUSTER_OPERATOR_NAME, + _install_multi_cluster_operator, + run_kube_config_creation_tool, + run_multi_cluster_recovery_tool, +) + +from . import prepare_multi_cluster_namespaces +from .conftest import cluster_spec_list, create_service_entries_objects +from .multi_cluster_clusterwide import create_namespace + + +@fixture(scope="module") +def mdba_ns(namespace: str): + return "{}-mdb-ns-a".format(namespace) + + +@fixture(scope="module") +def mdbb_ns(namespace: str): + return "{}-mdb-ns-b".format(namespace) + + +@fixture(scope="module") +def mongodb_multi_a( + central_cluster_client: kubernetes.client.ApiClient, + mdba_ns: str, + member_cluster_names: List[str], +) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml( + yaml_fixture("mongodb-multi.yaml"), "multi-replica-set", mdba_ns + ) + + resource["spec"]["clusterSpecList"] = cluster_spec_list( + member_cluster_names, [2, 1, 2] + ) + + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + create_or_update(resource) + return resource + + +@fixture(scope="module") +def mongodb_multi_b( + central_cluster_client: kubernetes.client.ApiClient, + mdbb_ns: str, + member_cluster_names: List[str], +) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml( + yaml_fixture("mongodb-multi.yaml"), "multi-replica-set", mdbb_ns + ) + + resource["spec"]["clusterSpecList"] = cluster_spec_list( + member_cluster_names, [2, 1, 2] + ) + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + create_or_update(resource) + return resource + + +@fixture(scope="module") +def install_operator( + namespace: str, + central_cluster_name: str, + multi_cluster_operator_installation_config: Dict[str, str], + central_cluster_client: client.ApiClient, + member_cluster_clients: List[MultiClusterClient], + member_cluster_names: List[str], + mdba_ns: str, + mdbb_ns: str, +) -> Operator: + os.environ["HELM_KUBECONTEXT"] = central_cluster_name + member_cluster_namespaces = mdba_ns + "," + mdbb_ns + run_kube_config_creation_tool( + member_cluster_names, + namespace, + namespace, + member_cluster_names, + True, + service_account_name=MULTI_CLUSTER_OPERATOR_NAME, + ) + + return _install_multi_cluster_operator( + namespace, + multi_cluster_operator_installation_config, + central_cluster_client, + member_cluster_clients, + { + "operator.deployment_name": MULTI_CLUSTER_OPERATOR_NAME, + "operator.name": MULTI_CLUSTER_OPERATOR_NAME, + "operator.createOperatorServiceAccount": "false", + "operator.watchNamespace": member_cluster_namespaces, + "multiCluster.performFailOver": "false", + }, + central_cluster_name, + operator_name=MULTI_CLUSTER_OPERATOR_NAME, + ) + + +@mark.e2e_multi_cluster_recover_clusterwide +def test_label_operator_namespace( + namespace: str, central_cluster_client: kubernetes.client.ApiClient +): + api = client.CoreV1Api(api_client=central_cluster_client) + + labels = {"istio-injection": "enabled"} + ns = api.read_namespace(name=namespace) + + ns.metadata.labels.update(labels) + api.replace_namespace(name=namespace, body=ns) + + +@mark.e2e_multi_cluster_recover_clusterwide +def test_create_namespaces( + namespace: str, + mdba_ns: str, + mdbb_ns: str, + central_cluster_client: kubernetes.client.ApiClient, + member_cluster_clients: List[MultiClusterClient], + evergreen_task_id: str, + multi_cluster_operator_installation_config: Dict[str, str], +): + image_pull_secret_name = multi_cluster_operator_installation_config[ + "registry.imagePullSecrets" + ] + image_pull_secret_data = read_secret(namespace, image_pull_secret_name) + + create_namespace( + central_cluster_client, + member_cluster_clients, + evergreen_task_id, + mdba_ns, + image_pull_secret_name, + image_pull_secret_data, + ) + + create_namespace( + central_cluster_client, + member_cluster_clients, + evergreen_task_id, + mdbb_ns, + image_pull_secret_name, + image_pull_secret_data, + ) + + +@mark.e2e_multi_cluster_recover_clusterwide +def test_create_service_entry(service_entries: List[CustomObject]): + for service_entry in service_entries: + create_or_update(service_entry) + + +@mark.e2e_multi_cluster_recover_clusterwide +def test_delete_cluster_role_and_binding( + central_cluster_client: kubernetes.client.ApiClient, + member_cluster_clients: List[MultiClusterClient], +): + role_names = [ + "mongodb-enterprise-operator-multi-cluster-role", + "mongodb-enterprise-operator-multi-cluster", + "mongodb-enterprise-operator-multi-cluster-role-binding", + ] + + for name in role_names: + delete_cluster_role(name, central_cluster_client) + delete_cluster_role_binding(name, central_cluster_client) + + for name in role_names: + for client in member_cluster_clients: + delete_cluster_role(name, client.api_client) + delete_cluster_role_binding(name, client.api_client) + + +@mark.e2e_multi_cluster_recover_clusterwide +def test_deploy_operator(install_operator: Operator): + install_operator.assert_is_running() + + +@mark.e2e_multi_cluster_recover_clusterwide +def test_prepare_namespace( + multi_cluster_operator_installation_config: Dict[str, str], + member_cluster_clients: List[MultiClusterClient], + central_cluster_name: str, + mdba_ns: str, + mdbb_ns: str, +): + prepare_multi_cluster_namespaces( + mdba_ns, + multi_cluster_operator_installation_config, + member_cluster_clients, + central_cluster_name, + ) + + prepare_multi_cluster_namespaces( + mdbb_ns, + multi_cluster_operator_installation_config, + member_cluster_clients, + central_cluster_name, + ) + + +@mark.e2e_multi_cluster_recover_clusterwide +def test_copy_configmap_and_secret_across_ns( + namespace: str, + central_cluster_client: client.ApiClient, + multi_cluster_operator_installation_config: Dict[str, str], + mdba_ns: str, + mdbb_ns: str, +): + data = KubernetesTester.read_configmap( + namespace, "my-project", api_client=central_cluster_client + ) + data["projectName"] = mdba_ns + create_or_update_configmap( + mdba_ns, "my-project", data, api_client=central_cluster_client + ) + + data["projectName"] = mdbb_ns + create_or_update_configmap( + mdbb_ns, "my-project", data, api_client=central_cluster_client + ) + + data = read_secret(namespace, "my-credentials", api_client=central_cluster_client) + create_or_update_secret( + mdba_ns, "my-credentials", data, api_client=central_cluster_client + ) + create_or_update_secret( + mdbb_ns, "my-credentials", data, api_client=central_cluster_client + ) + + +@mark.e2e_multi_cluster_recover_clusterwide +def test_create_mongodb_multi_nsa_nsb( + mongodb_multi_a: MongoDBMulti, mongodb_multi_b: MongoDBMulti +): + mongodb_multi_a.assert_reaches_phase(Phase.Running, timeout=1500) + mongodb_multi_b.assert_reaches_phase(Phase.Running, timeout=1500) + + +@mark.e2e_multi_cluster_recover_clusterwide +def test_update_service_entry_block_cluster3_traffic( + namespace: str, + central_cluster_client: kubernetes.client.ApiClient, + member_cluster_names: List[str], +): + # TODO: add a way to simulate local operator connection cut-off + service_entries = create_service_entries_objects( + namespace, + central_cluster_client, + [member_cluster_names[0], member_cluster_names[1]], + ) + for service_entry in service_entries: + print(f"service_entry={service_entries}") + service_entry.update() + + +@mark.e2e_multi_cluster_recover_clusterwide +def test_mongodb_multi_nsa_enters_failed_stated(mongodb_multi_a: MongoDBMulti): + mongodb_multi_a.load() + mongodb_multi_a.assert_abandons_phase(Phase.Running, timeout=50) + mongodb_multi_a.assert_reaches_phase(Phase.Failed, timeout=100) + + +@mark.e2e_multi_cluster_recover_clusterwide +def test_mongodb_multi_nsb_enters_failed_stated(mongodb_multi_b: MongoDBMulti): + mongodb_multi_b.load() + mongodb_multi_b.assert_abandons_phase(Phase.Running, timeout=50) + mongodb_multi_b.assert_reaches_phase(Phase.Failed, timeout=100) + + +@mark.e2e_multi_cluster_recover_clusterwide +def test_recover_operator_remove_cluster( + member_cluster_names: List[str], + namespace: str, + mdba_ns: str, + mdbb_ns: str, + central_cluster_client: kubernetes.client.ApiClient, +): + return_code = run_multi_cluster_recovery_tool( + member_cluster_names[:-1], namespace, namespace, True + ) + assert return_code == 0 + operator = Operator( + name=MULTI_CLUSTER_OPERATOR_NAME, + namespace=namespace, + api_client=central_cluster_client, + ) + operator._wait_for_operator_ready() + operator.assert_is_running() + + +@mark.e2e_multi_cluster_recover_clusterwide +def test_mongodb_multi_nsa_recovers_removing_cluster(mongodb_multi_a: MongoDBMulti): + mongodb_multi_a.load() + + mongodb_multi_a["metadata"]["annotations"]["failedClusters"] = None + mongodb_multi_a["spec"]["clusterSpecList"].pop() + mongodb_multi_a.update() + + mongodb_multi_a.assert_reaches_phase(Phase.Running, timeout=1500) + + +@mark.e2e_multi_cluster_recover_clusterwide +def test_mongodb_multi_nsb_recovers_removing_cluster(mongodb_multi_b: MongoDBMulti): + mongodb_multi_b.load() + + mongodb_multi_b["metadata"]["annotations"]["failedClusters"] = None + mongodb_multi_b["spec"]["clusterSpecList"].pop() + mongodb_multi_b.update() + + mongodb_multi_b.assert_reaches_phase(Phase.Running, timeout=1500) diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_recover_network_partition.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_recover_network_partition.py new file mode 100644 index 000000000..f0472200d --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_recover_network_partition.py @@ -0,0 +1,116 @@ +from typing import List +from pytest import mark, fixture + +import kubernetes +from kubetester import create_or_update +from kubetester.mongodb import Phase +from kubetester.mongodb_multi import MongoDBMulti +from kubetester.operator import Operator +from kubetester.kubetester import fixture as yaml_fixture +from kubernetes import client +from kubeobject import CustomObject + +from tests.conftest import run_multi_cluster_recovery_tool, MULTI_CLUSTER_OPERATOR_NAME +from .conftest import create_service_entries_objects, cluster_spec_list + +RESOURCE_NAME = "multi-replica-set" + + +@fixture(scope="module") +def mongodb_multi( + central_cluster_client: client.ApiClient, + namespace: str, + member_cluster_names: list[str], +) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml(yaml_fixture("mongodb-multi.yaml"), RESOURCE_NAME, namespace) + resource["spec"]["persistent"] = False + resource["spec"]["clusterSpecList"] = cluster_spec_list(member_cluster_names, [2, 1, 2]) + resource.api = client.CustomObjectsApi(central_cluster_client) + + return resource + + +@mark.e2e_multi_cluster_recover_network_partition +def test_label_namespace(namespace: str, central_cluster_client: client.ApiClient): + + api = client.CoreV1Api(api_client=central_cluster_client) + + labels = {"istio-injection": "enabled"} + ns = api.read_namespace(name=namespace) + + ns.metadata.labels.update(labels) + api.replace_namespace(name=namespace, body=ns) + + +@mark.e2e_multi_cluster_recover_network_partition +def test_create_service_entry(service_entries: List[CustomObject]): + for service_entry in service_entries: + create_or_update(service_entry) + + +@mark.e2e_multi_cluster_recover_network_partition +def test_deploy_operator(multi_cluster_operator_manual_remediation: Operator): + multi_cluster_operator_manual_remediation.assert_is_running() + + +@mark.e2e_multi_cluster_recover_network_partition +def test_create_mongodb_multi(mongodb_multi: MongoDBMulti): + create_or_update(mongodb_multi) + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=700) + + +@mark.e2e_multi_cluster_recover_network_partition +def test_update_service_entry_block_cluster3_traffic( + namespace: str, + central_cluster_client: kubernetes.client.ApiClient, + member_cluster_names: List[str], +): + + service_entries = create_service_entries_objects( + namespace, + central_cluster_client, + [member_cluster_names[0], member_cluster_names[1]], + ) + for service_entry in service_entries: + print(f"service_entry={service_entries}") + service_entry.update() + + +@mark.e2e_multi_cluster_recover_network_partition +def test_mongodb_multi_enters_failed_state( + mongodb_multi: MongoDBMulti, + namespace: str, + central_cluster_client: client.ApiClient, +): + mongodb_multi.load() + mongodb_multi.assert_abandons_phase(Phase.Running, timeout=50) + mongodb_multi.assert_reaches_phase(Phase.Failed, timeout=100) + + +@mark.e2e_multi_cluster_recover_network_partition +def test_recover_operator_remove_cluster( + member_cluster_names: List[str], + namespace: str, + central_cluster_client: client.ApiClient, +): + return_code = run_multi_cluster_recovery_tool(member_cluster_names[:-1], namespace, namespace) + assert return_code == 0 + operator = Operator( + name=MULTI_CLUSTER_OPERATOR_NAME, + namespace=namespace, + api_client=central_cluster_client, + ) + operator._wait_for_operator_ready() + operator.assert_is_running() + + +@mark.e2e_multi_cluster_recover_network_partition +def test_mongodb_multi_recovers_removing_cluster(mongodb_multi: MongoDBMulti, member_cluster_names: List[str]): + mongodb_multi.load() + + mongodb_multi["metadata"]["annotations"]["failedClusters"] = None + mongodb_multi["spec"]["clusterSpecList"].pop() + mongodb_multi.update() + mongodb_multi.assert_abandons_phase(Phase.Running, timeout=50) + + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=1500) diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_replica_set.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_replica_set.py new file mode 100644 index 000000000..4fe3e7749 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_replica_set.py @@ -0,0 +1,201 @@ +from typing import Dict, List + +import kubernetes +import pytest +from kubernetes import client +from kubernetes.client.rest import ApiException + +from kubetester import create_or_update, delete_statefulset +from kubetester.kubetester import ( + fixture as yaml_fixture, + skip_if_local, + KubernetesTester, +) +from kubetester.mongodb import Phase +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from kubetester.operator import Operator +from tests.multicluster.conftest import cluster_spec_list + +MONGODB_PORT = 30000 + + +@pytest.fixture(scope="module") +def mongodb_multi( + central_cluster_client: kubernetes.client.ApiClient, + namespace: str, + member_cluster_names, +) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml( + yaml_fixture("mongodb-multi-central-sts-override.yaml"), + "multi-replica-set", + namespace, + ) + resource["spec"]["persistent"] = False + resource["spec"]["clusterSpecList"] = cluster_spec_list(member_cluster_names, [2, 1, 2]) + + additional_mongod_config = { + "systemLog": {"logAppend": True, "verbosity": 4}, + "operationProfiling": {"mode": "slowOp"}, + "net": {"port": MONGODB_PORT}, + } + + resource["spec"]["additionalMongodConfig"] = additional_mongod_config + + # TODO: incorporate this into the base class. + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + + create_or_update(resource) + return resource + + +@pytest.mark.e2e_multi_cluster_replica_set +def test_create_kube_config_file(cluster_clients: Dict, central_cluster_name: str, member_cluster_names: str): + clients = cluster_clients + + assert len(clients) == 4 + for member_cluster_name in member_cluster_names: + assert member_cluster_name in clients + assert central_cluster_name in clients + + +@pytest.mark.e2e_multi_cluster_replica_set +def test_deploy_operator(multi_cluster_operator: Operator): + multi_cluster_operator.assert_is_running() + + +@pytest.mark.e2e_multi_cluster_replica_set +def test_create_mongodb_multi(mongodb_multi: MongoDBMulti): + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=2000) + + +@pytest.mark.e2e_multi_cluster_replica_set +def test_statefulset_is_created_across_multiple_clusters( + mongodb_multi: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], +): + statefulsets = mongodb_multi.read_statefulsets(member_cluster_clients) + cluster_one_client = member_cluster_clients[0] + cluster_one_sts = statefulsets[cluster_one_client.cluster_name] + assert cluster_one_sts.status.ready_replicas == 2 + + cluster_two_client = member_cluster_clients[1] + cluster_two_sts = statefulsets[cluster_two_client.cluster_name] + assert cluster_two_sts.status.ready_replicas == 1 + + cluster_three_client = member_cluster_clients[2] + cluster_three_sts = statefulsets[cluster_three_client.cluster_name] + assert cluster_three_sts.status.ready_replicas == 2 + + +@pytest.mark.e2e_multi_cluster_replica_set +def test_pvc_not_created( + mongodb_multi: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], + namespace: str, +): + with pytest.raises(kubernetes.client.exceptions.ApiException) as e: + client.CoreV1Api(api_client=member_cluster_clients[0].api_client).read_namespaced_persistent_volume_claim( + f"data-{mongodb_multi.name}-{0}-{0}", namespace + ) + assert e.value.reason == "Not Found" + + +@skip_if_local +@pytest.mark.e2e_multi_cluster_replica_set +def test_replica_set_is_reachable(mongodb_multi: MongoDBMulti): + tester = mongodb_multi.tester(port=MONGODB_PORT) + tester.assert_connectivity() + + +@pytest.mark.e2e_multi_cluster_replica_set +def test_statefulset_overrides(mongodb_multi: MongoDBMulti, member_cluster_clients: List[MultiClusterClient]): + statefulsets = mongodb_multi.read_statefulsets(member_cluster_clients) + # assert sts.podspec override in cluster1 + cluster_one_client = member_cluster_clients[0] + cluster_one_sts = statefulsets[cluster_one_client.cluster_name] + assert_container_in_sts("sidecar1", cluster_one_sts) + + +@pytest.mark.e2e_multi_cluster_replica_set +def test_mongodb_options(mongodb_multi: MongoDBMulti): + automation_config_tester = mongodb_multi.get_automation_config_tester() + for process in automation_config_tester.get_replica_set_processes(mongodb_multi.name): + assert process["args2_6"]["systemLog"]["verbosity"] == 4 + assert process["args2_6"]["systemLog"]["logAppend"] + assert process["args2_6"]["operationProfiling"]["mode"] == "slowOp" + assert process["args2_6"]["net"]["port"] == MONGODB_PORT + + +@pytest.mark.e2e_multi_cluster_replica_set +def test_update_additional_options(mongodb_multi: MongoDBMulti, central_cluster_client: kubernetes.client.ApiClient): + mongodb_multi["spec"]["additionalMongodConfig"]["systemLog"]["verbosity"] = 2 + mongodb_multi["spec"]["additionalMongodConfig"]["net"]["maxIncomingConnections"] = 100 + # update uses json merge+patch which means that deleting keys is done by setting them to None + mongodb_multi["spec"]["additionalMongodConfig"]["operationProfiling"] = None + + mongodb_multi.update() + + mongodb_multi.assert_abandons_phase(Phase.Running) + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=700) + + +@pytest.mark.e2e_multi_cluster_replica_set +def test_mongodb_options_were_updated(mongodb_multi: MongoDBMulti): + automation_config_tester = mongodb_multi.get_automation_config_tester() + for process in automation_config_tester.get_replica_set_processes(mongodb_multi.name): + assert process["args2_6"]["systemLog"]["verbosity"] == 2 + assert process["args2_6"]["systemLog"]["logAppend"] + assert process["args2_6"]["net"]["maxIncomingConnections"] == 100 + assert process["args2_6"]["net"]["port"] == MONGODB_PORT + # the mode setting has been removed + assert "mode" not in process["args2_6"]["operationProfiling"] + + +@pytest.mark.e2e_multi_cluster_replica_set +def test_delete_member_cluster_sts( + namespace: str, + mongodb_multi: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], +): + date = mongodb_multi.get_status_last_transition_time() + + sts_name = "{}-0".format(mongodb_multi.name) + delete_statefulset( + namespace=namespace, + name=sts_name, + api_client=member_cluster_clients[0].api_client, + ) + + # abandons running phase since the statefulset in cluster1 has been deleted + mongodb_multi.assert_state_transition_happens(date) + + # the operator should reconcile and recreate the statefulset + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=400) + + +@pytest.mark.e2e_multi_cluster_replica_set +def test_cleanup_on_mdbm_delete(mongodb_multi: MongoDBMulti, member_cluster_clients: List[MultiClusterClient]): + statefulsets = mongodb_multi.read_statefulsets(member_cluster_clients) + cluster_one_client = member_cluster_clients[0] + cluster_one_sts = statefulsets[cluster_one_client.cluster_name] + + mongodb_multi.delete() + + def check_sts_not_exist(): + try: + client.AppsV1Api(api_client=cluster_one_client.api_client).read_namespaced_stateful_set( + cluster_one_sts.metadata.name, cluster_one_sts.metadata.namespace + ) + except ApiException as e: + if e.reason == "Not Found": + return True + return False + else: + return False + + KubernetesTester.wait_until(check_sts_not_exist, timeout=100) + + +def assert_container_in_sts(container_name: str, sts: client.V1StatefulSet): + container_names = [c.name for c in sts.spec.template.spec.containers] + assert container_name in container_names diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_replica_set_deletion.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_replica_set_deletion.py new file mode 100644 index 000000000..5fb3ea9c3 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_replica_set_deletion.py @@ -0,0 +1,118 @@ +from typing import List + +import kubernetes +import pytest + +from kubetester import wait_until, create_or_update +from kubetester.automation_config_tester import AutomationConfigTester +from kubetester.mongodb import Phase +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from kubetester.operator import Operator +from kubetester.kubetester import fixture as yaml_fixture, KubernetesTester +from tests.multicluster.conftest import cluster_spec_list + + +@pytest.fixture(scope="module") +def mongodb_multi( + central_cluster_client: kubernetes.client.ApiClient, namespace: str, member_cluster_names: list[str] +) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml(yaml_fixture("mongodb-multi.yaml"), "multi-replica-set", namespace) + + # TODO: incorporate this into the base class. + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + resource["spec"]["clusterSpecList"] = cluster_spec_list(member_cluster_names, [2, 1, 2]) + + return create_or_update(resource) + + +@pytest.mark.e2e_multi_cluster_replica_set_deletion +def test_deploy_operator(multi_cluster_operator: Operator): + multi_cluster_operator.assert_is_running() + + +@pytest.mark.e2e_multi_cluster_replica_set_deletion +def test_create_mongodb_multi(mongodb_multi: MongoDBMulti): + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=700) + + +@pytest.mark.e2e_multi_cluster_replica_set_deletion +def test_automation_config_has_been_updated(mongodb_multi: MongoDBMulti): + tester = AutomationConfigTester(KubernetesTester.get_automation_config()) + processes = tester.get_replica_set_processes(mongodb_multi.name) + assert len(processes) == 5 + + +@pytest.mark.e2e_multi_cluster_replica_set_deletion +def test_delete_mongodb_multi( + mongodb_multi: MongoDBMulti, +): + mongodb_multi.load() + + # TODO: uncomment when change is merged. + # mongodb_multi.delete() + + body = kubernetes.client.V1DeleteOptions() + mongodb_multi.api.delete_namespaced_custom_object( + mongodb_multi.group, + mongodb_multi.version, + mongodb_multi.namespace, + mongodb_multi.plural, + mongodb_multi.name, + body=body, + ) + + def wait_for_deleted() -> bool: + try: + mongodb_multi.load() + return False + except kubernetes.client.ApiException: + return True + + wait_until(wait_for_deleted, timeout=60) + + +@pytest.mark.e2e_multi_cluster_replica_set_deletion +def test_deployment_has_been_removed_from_automation_config(): + def wait_until_automation_config_is_clean() -> bool: + tester = AutomationConfigTester(KubernetesTester.get_automation_config()) + try: + tester.assert_empty() + return True + except AssertionError as e: + print(e) + return False + + wait_until(wait_until_automation_config_is_clean, timeout=60) + + +@pytest.mark.e2e_multi_cluster_replica_set_deletion +def test_kubernetes_resources_have_been_cleaned_up( + mongodb_multi: MongoDBMulti, member_cluster_clients: List[MultiClusterClient] +): + def wait_until_secrets_are_removed() -> bool: + try: + mongodb_multi.read_services(member_cluster_clients) + return False + except kubernetes.client.ApiException as e: + print(e) + return True + + def wait_until_statefulsets_are_removed() -> bool: + try: + mongodb_multi.read_statefulsets(member_cluster_clients) + return False + except kubernetes.client.ApiException as e: + print(e) + return True + + def wait_until_configmaps_are_removed() -> bool: + try: + mongodb_multi.read_configmaps(member_cluster_clients) + return False + except kubernetes.client.ApiException as e: + print(e) + return True + + wait_until(wait_until_secrets_are_removed, timeout=60) + wait_until(wait_until_statefulsets_are_removed, timeout=60) + wait_until(wait_until_configmaps_are_removed, timeout=60) diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_replica_set_ignore_unknown_users.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_replica_set_ignore_unknown_users.py new file mode 100644 index 000000000..005353e35 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_replica_set_ignore_unknown_users.py @@ -0,0 +1,63 @@ +from typing import Dict, List +import kubernetes +from pytest import mark, fixture + +from kubetester import create_or_update +from kubetester.mongodb import Phase +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from kubetester.automation_config_tester import AutomationConfigTester +from kubetester.operator import Operator +from kubetester.kubetester import fixture as yaml_fixture, KubernetesTester +from kubernetes import client + +from tests.multicluster.conftest import cluster_spec_list + + +@fixture(scope="module") +def mongodb_multi( + central_cluster_client: kubernetes.client.ApiClient, + namespace: str, + member_cluster_names: list[str], +) -> MongoDBMulti: + + resource = MongoDBMulti.from_yaml( + yaml_fixture("mongodb-multi.yaml"), + "multi-replica-set", + namespace, + ) + + print(resource) + resource["spec"]["security"] = {"authentication": {"enabled": True, "modes": ["SCRAM"]}} + + resource["spec"]["security"]["authentication"]["ignoreUnknownUsers"] = True + resource["spec"]["clusterSpecList"] = cluster_spec_list(member_cluster_names, [2, 1, 2]) + + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + + return create_or_update(resource) + + +@mark.e2e_multi_cluster_replica_set_ignore_unknown_users +def test_replica_set(multi_cluster_operator: Operator, mongodb_multi: MongoDBMulti): + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=800) + + +@mark.e2e_multi_cluster_replica_set_ignore_unknown_users +def test_authoritative_set_false(mongodb_multi: MongoDBMulti): + tester = AutomationConfigTester(KubernetesTester.get_automation_config()) + tester.assert_authoritative_set(False) + + +@mark.e2e_multi_cluster_replica_set_ignore_unknown_users +def test_set_ignore_unknown_users_false(mongodb_multi: MongoDBMulti): + mongodb_multi.load() + mongodb_multi["spec"]["security"]["authentication"]["ignoreUnknownUsers"] = False + mongodb_multi.update() + mongodb_multi.assert_abandons_phase(Phase.Running) + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=800) + + +@mark.e2e_multi_cluster_replica_set_ignore_unknown_users +def test_authoritative_set_true(mongodb_multi: MongoDBMulti): + tester = AutomationConfigTester(KubernetesTester.get_automation_config()) + tester.assert_authoritative_set(True) diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_replica_set_member_options.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_replica_set_member_options.py new file mode 100644 index 000000000..fb604403a --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_replica_set_member_options.py @@ -0,0 +1,224 @@ +from typing import Dict, List + +import kubernetes +import pytest +from kubernetes import client +from kubernetes.client.rest import ApiException + +from kubetester import create_or_update +from kubetester.kubetester import ( + fixture as yaml_fixture, + skip_if_local, + KubernetesTester, +) +from kubetester.mongodb import Phase +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from kubetester.operator import Operator +from tests.multicluster.conftest import cluster_spec_list + + +@pytest.fixture(scope="module") +def mongodb_multi( + central_cluster_client: kubernetes.client.ApiClient, + namespace: str, + member_cluster_names, + custom_mdb_version: str, +) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml( + yaml_fixture("mongodb-multi.yaml"), + "multi-replica-set", + namespace, + ) + resource.set_version(custom_mdb_version) + member_options = [ + [ + { + "votes": 1, + "priority": "0.3", + "tags": { + "cluster": "cluster-1", + "region": "weur", + }, + }, + { + "votes": 1, + "priority": "0.7", + "tags": { + "cluster": "cluster-1", + "region": "eeur", + }, + }, + ], + [ + { + "votes": 1, + "priority": "0.2", + "tags": { + "cluster": "cluster-2", + "region": "apac", + }, + }, + ], + [ + { + "votes": 1, + "priority": "1.3", + "tags": { + "cluster": "cluster-3", + "region": "nwus", + }, + }, + { + "votes": 1, + "priority": "2.7", + "tags": { + "cluster": "cluster-3", + "region": "seus", + }, + }, + ], + ] + resource["spec"]["clusterSpecList"] = cluster_spec_list( + member_cluster_names, [2, 1, 2], member_options + ) + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + + create_or_update(resource) + return resource + + +@pytest.mark.e2e_multi_cluster_replica_set_member_options +def test_create_kube_config_file( + cluster_clients: Dict, central_cluster_name: str, member_cluster_names: str +): + clients = cluster_clients + + assert len(clients) == 4 + for member_cluster_name in member_cluster_names: + assert member_cluster_name in clients + assert central_cluster_name in clients + + +@pytest.mark.e2e_multi_cluster_replica_set_member_options +def test_deploy_operator(multi_cluster_operator: Operator): + multi_cluster_operator.assert_is_running() + + +@pytest.mark.e2e_multi_cluster_replica_set_member_options +def test_create_mongodb_multi(mongodb_multi: MongoDBMulti): + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=700) + + +@pytest.mark.e2e_multi_cluster_replica_set_member_options +def test_mongodb_multi_member_options_ac(mongodb_multi: MongoDBMulti): + mongodb_multi.load() + config = mongodb_multi.get_automation_config_tester().automation_config + rs = config["replicaSets"] + member1 = rs[0]["members"][0] + member2 = rs[0]["members"][1] + member3 = rs[0]["members"][2] + member4 = rs[0]["members"][3] + member5 = rs[0]["members"][4] + + assert member1["votes"] == 1 + assert member1["priority"] == 0.3 + assert member1["tags"] == {"cluster": "cluster-1", "region": "weur"} + + assert member2["votes"] == 1 + assert member2["priority"] == 0.7 + assert member2["tags"] == {"cluster": "cluster-1", "region": "eeur"} + + assert member3["votes"] == 1 + assert member3["priority"] == 0.2 + assert member3["tags"] == {"cluster": "cluster-2", "region": "apac"} + + assert member4["votes"] == 1 + assert member4["priority"] == 1.3 + assert member4["tags"] == {"cluster": "cluster-3", "region": "nwus"} + + assert member5["votes"] == 1 + assert member5["priority"] == 2.7 + assert member5["tags"] == {"cluster": "cluster-3", "region": "seus"} + + +@pytest.mark.e2e_multi_cluster_replica_set_member_options +def test_mongodb_multi_update_member_options(mongodb_multi: MongoDBMulti): + mongodb_multi.load() + + mongodb_multi["spec"]["clusterSpecList"][0]["memberConfig"][0] = { + "votes": 1, + "priority": "1.3", + "tags": { + "cluster": "cluster-1", + "region": "weur", + "app": "backend", + }, + } + mongodb_multi.update() + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=700) + + config = mongodb_multi.get_automation_config_tester().automation_config + rs = config["replicaSets"] + + updated_member = rs[0]["members"][0] + assert updated_member["votes"] == 1 + assert updated_member["priority"] == 1.3 + assert updated_member["tags"] == { + "cluster": "cluster-1", + "region": "weur", + "app": "backend", + } + + +@pytest.mark.e2e_multi_cluster_replica_set_member_options +def test_mongodb_multi_set_member_votes_to_0(mongodb_multi: MongoDBMulti): + mongodb_multi.load() + + mongodb_multi["spec"]["clusterSpecList"][1]["memberConfig"][0]["votes"] = 0 + mongodb_multi["spec"]["clusterSpecList"][1]["memberConfig"][0]["priority"] = "0.0" + mongodb_multi.update() + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=700) + + config = mongodb_multi.get_automation_config_tester().automation_config + rs = config["replicaSets"] + + updated_member = rs[0]["members"][2] + assert updated_member["votes"] == 0 + assert updated_member["priority"] == 0.0 + + +@pytest.mark.e2e_multi_cluster_replica_set_member_options +def test_mongodb_multi_set_invalid_votes_and_priority(mongodb_multi: MongoDBMulti): + mongodb_multi.load() + + mongodb_multi["spec"]["clusterSpecList"][1]["memberConfig"][0]["votes"] = 0 + mongodb_multi["spec"]["clusterSpecList"][1]["memberConfig"][0]["priority"] = "0.7" + mongodb_multi.update() + mongodb_multi.assert_reaches_phase( + Phase.Failed, + msg_regexp=".*cannot have 0 votes when priority is greater than 0", + ) + + +@pytest.mark.e2e_multi_cluster_replica_set_member_options +def test_mongodb_multi_set_recover_valid_member_options(mongodb_multi: MongoDBMulti): + mongodb_multi.load() + # A member with priority 0.0 could still be a voting member. It cannot become primary and cannot trigger elections. + # https://www.mongodb.com/docs/v5.0/core/replica-set-priority-0-member/#priority-0-replica-set-members + mongodb_multi["spec"]["clusterSpecList"][1]["memberConfig"][0]["votes"] = 1 + mongodb_multi["spec"]["clusterSpecList"][1]["memberConfig"][0]["priority"] = "0.0" + mongodb_multi.update() + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=700) + + +@pytest.mark.e2e_multi_cluster_replica_set_member_options +def test_mongodb_multi_set_only_one_vote_per_member(mongodb_multi: MongoDBMulti): + mongodb_multi.load() + + mongodb_multi["spec"]["clusterSpecList"][2]["memberConfig"][1]["votes"] = 3 + mongodb_multi["spec"]["clusterSpecList"][2]["memberConfig"][1]["priority"] = "0.1" + mongodb_multi.update() + mongodb_multi.assert_reaches_phase( + Phase.Failed, + msg_regexp=".*cannot have greater than 1 vote", + ) diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_replica_set_scale_down.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_replica_set_scale_down.py new file mode 100644 index 000000000..f5b64a2d0 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_replica_set_scale_down.py @@ -0,0 +1,140 @@ +from typing import List + +import kubernetes +import pytest + +from kubetester.automation_config_tester import AutomationConfigTester +from kubetester.certs import create_multi_cluster_mongodb_tls_certs +from kubetester.mongodb import Phase +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from kubetester.mongotester import with_tls +from kubetester.operator import Operator +from kubetester.kubetester import ( + fixture as yaml_fixture, + skip_if_local, +) +from tests.multicluster.conftest import cluster_spec_list + +RESOURCE_NAME = "multi-replica-set" +BUNDLE_SECRET_NAME = f"prefix-{RESOURCE_NAME}-cert" + + +@pytest.fixture(scope="module") +def mongodb_multi_unmarshalled( + namespace: str, + multi_cluster_issuer_ca_configmap: str, + central_cluster_client: kubernetes.client.ApiClient, + member_cluster_names: list[str], +) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml(yaml_fixture("mongodb-multi.yaml"), RESOURCE_NAME, namespace) + # start at one member in each cluster + resource["spec"]["clusterSpecList"] = cluster_spec_list(member_cluster_names, [2, 1, 2]) + + resource["spec"]["security"] = { + "certsSecretPrefix": "prefix", + "tls": { + "ca": multi_cluster_issuer_ca_configmap, + }, + } + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + return resource + + +@pytest.fixture(scope="module") +def server_certs( + multi_cluster_issuer: str, + mongodb_multi_unmarshalled: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], + central_cluster_client: kubernetes.client.ApiClient, +): + + return create_multi_cluster_mongodb_tls_certs( + multi_cluster_issuer, + BUNDLE_SECRET_NAME, + member_cluster_clients, + central_cluster_client, + mongodb_multi_unmarshalled, + ) + + +@pytest.fixture(scope="module") +def mongodb_multi(mongodb_multi_unmarshalled: MongoDBMulti, server_certs: str) -> MongoDBMulti: + return mongodb_multi_unmarshalled.create() + + +@pytest.mark.e2e_multi_cluster_replica_set_scale_down +def test_deploy_operator(multi_cluster_operator: Operator): + multi_cluster_operator.assert_is_running() + + +@pytest.mark.e2e_multi_cluster_replica_set_scale_down +def test_create_mongodb_multi(mongodb_multi: MongoDBMulti): + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=1200) + + +@pytest.mark.e2e_multi_cluster_replica_set_scale_down +def test_statefulsets_have_been_created_correctly( + mongodb_multi: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], +): + statefulsets = mongodb_multi.read_statefulsets(member_cluster_clients) + cluster_one_client = member_cluster_clients[0] + cluster_one_sts = statefulsets[cluster_one_client.cluster_name] + assert cluster_one_sts.status.ready_replicas == 2 + + cluster_two_client = member_cluster_clients[1] + cluster_two_sts = statefulsets[cluster_two_client.cluster_name] + assert cluster_two_sts.status.ready_replicas == 1 + + cluster_three_client = member_cluster_clients[2] + cluster_three_sts = statefulsets[cluster_three_client.cluster_name] + assert cluster_three_sts.status.ready_replicas == 2 + + +@pytest.mark.e2e_multi_cluster_replica_set_scale_down +def test_ops_manager_has_been_updated_correctly_before_scaling(): + ac = AutomationConfigTester() + ac.assert_processes_size(5) + + +@pytest.mark.e2e_multi_cluster_replica_set_scale_down +def test_scale_mongodb_multi(mongodb_multi: MongoDBMulti): + mongodb_multi.load() + mongodb_multi["spec"]["clusterSpecList"][0]["members"] = 1 + mongodb_multi["spec"]["clusterSpecList"][1]["members"] = 1 + mongodb_multi["spec"]["clusterSpecList"][2]["members"] = 1 + mongodb_multi.update() + + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=1800) + + +@pytest.mark.e2e_multi_cluster_replica_set_scale_down +def test_statefulsets_have_been_scaled_down_correctly( + mongodb_multi: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], +): + statefulsets = mongodb_multi.read_statefulsets(member_cluster_clients) + cluster_one_client = member_cluster_clients[0] + cluster_one_sts = statefulsets[cluster_one_client.cluster_name] + assert cluster_one_sts.status.ready_replicas == 1 + + cluster_two_client = member_cluster_clients[1] + cluster_two_sts = statefulsets[cluster_two_client.cluster_name] + assert cluster_two_sts.status.ready_replicas == 1 + + cluster_three_client = member_cluster_clients[2] + cluster_three_sts = statefulsets[cluster_three_client.cluster_name] + assert cluster_three_sts.status.ready_replicas == 1 + + +@pytest.mark.e2e_multi_cluster_replica_set_scale_down +def test_ops_manager_has_been_updated_correctly_after_scaling(): + ac = AutomationConfigTester() + ac.assert_processes_size(3) + + +@skip_if_local +@pytest.mark.e2e_multi_cluster_replica_set_scale_down +def test_replica_set_is_reachable(mongodb_multi: MongoDBMulti, ca_path: str): + tester = mongodb_multi.tester() + tester.assert_connectivity(opts=[with_tls(use_tls=True, ca_path=ca_path)]) diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_replica_set_scale_up.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_replica_set_scale_up.py new file mode 100644 index 000000000..dbc7a9134 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_replica_set_scale_up.py @@ -0,0 +1,143 @@ +from typing import List + +import kubernetes +import pytest + +from kubetester.automation_config_tester import AutomationConfigTester +from kubetester.certs import create_multi_cluster_mongodb_tls_certs +from kubetester.mongodb import Phase +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from kubetester.mongotester import with_tls +from kubetester.operator import Operator +from kubetester.kubetester import ( + fixture as yaml_fixture, + skip_if_local, +) +from tests.multicluster.conftest import cluster_spec_list + +RESOURCE_NAME = "multi-replica-set" +BUNDLE_SECRET_NAME = f"prefix-{RESOURCE_NAME}-cert" + + +@pytest.fixture(scope="module") +def mongodb_multi_unmarshalled( + namespace: str, + multi_cluster_issuer_ca_configmap: str, + central_cluster_client: kubernetes.client.ApiClient, + member_cluster_names: List[str], +) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml(yaml_fixture("mongodb-multi.yaml"), RESOURCE_NAME, namespace) + resource["spec"]["clusterSpecList"] = cluster_spec_list(member_cluster_names, [2, 1, 2]) + + resource["spec"]["security"] = { + "certsSecretPrefix": "prefix", + "tls": { + "ca": multi_cluster_issuer_ca_configmap, + }, + } + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + return resource + + +@pytest.fixture(scope="module") +def server_certs( + multi_cluster_issuer: str, + mongodb_multi_unmarshalled: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], + central_cluster_client: kubernetes.client.ApiClient, +): + + return create_multi_cluster_mongodb_tls_certs( + multi_cluster_issuer, + BUNDLE_SECRET_NAME, + member_cluster_clients, + central_cluster_client, + mongodb_multi_unmarshalled, + ) + + +@pytest.fixture(scope="module") +def mongodb_multi(mongodb_multi_unmarshalled: MongoDBMulti, server_certs: str) -> MongoDBMulti: + # we have created certs for all 5 members, but want to start at only 3. + mongodb_multi_unmarshalled["spec"]["clusterSpecList"][0]["members"] = 1 + mongodb_multi_unmarshalled["spec"]["clusterSpecList"][1]["members"] = 1 + mongodb_multi_unmarshalled["spec"]["clusterSpecList"][2]["members"] = 1 + return mongodb_multi_unmarshalled.create() + + +@pytest.mark.e2e_multi_cluster_replica_set_scale_up +def test_deploy_operator(multi_cluster_operator: Operator): + multi_cluster_operator.assert_is_running() + + +@pytest.mark.e2e_multi_cluster_replica_set_scale_up +def test_create_mongodb_multi(mongodb_multi: MongoDBMulti): + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=600) + + +@pytest.mark.e2e_multi_cluster_replica_set_scale_up +def test_statefulsets_have_been_created_correctly( + mongodb_multi: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], +): + statefulsets = mongodb_multi.read_statefulsets(member_cluster_clients) + cluster_one_client = member_cluster_clients[0] + cluster_one_sts = statefulsets[cluster_one_client.cluster_name] + assert cluster_one_sts.status.ready_replicas == 1 + + cluster_two_client = member_cluster_clients[1] + cluster_two_sts = statefulsets[cluster_two_client.cluster_name] + assert cluster_two_sts.status.ready_replicas == 1 + + cluster_three_client = member_cluster_clients[2] + cluster_three_sts = statefulsets[cluster_three_client.cluster_name] + assert cluster_three_sts.status.ready_replicas == 1 + + +@pytest.mark.e2e_multi_cluster_replica_set_scale_up +def test_ops_manager_has_been_updated_correctly_before_scaling(): + ac = AutomationConfigTester() + ac.assert_processes_size(3) + + +@pytest.mark.e2e_multi_cluster_replica_set_scale_up +def test_scale_mongodb_multi(mongodb_multi: MongoDBMulti): + mongodb_multi.load() + mongodb_multi["spec"]["clusterSpecList"][0]["members"] = 2 + mongodb_multi["spec"]["clusterSpecList"][1]["members"] = 1 + mongodb_multi["spec"]["clusterSpecList"][2]["members"] = 2 + mongodb_multi.update() + + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=1800) + + +@pytest.mark.e2e_multi_cluster_replica_set_scale_up +def test_statefulsets_have_been_scaled_up_correctly( + mongodb_multi: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], +): + statefulsets = mongodb_multi.read_statefulsets(member_cluster_clients) + cluster_one_client = member_cluster_clients[0] + cluster_one_sts = statefulsets[cluster_one_client.cluster_name] + assert cluster_one_sts.status.ready_replicas == 2 + + cluster_two_client = member_cluster_clients[1] + cluster_two_sts = statefulsets[cluster_two_client.cluster_name] + assert cluster_two_sts.status.ready_replicas == 1 + + cluster_three_client = member_cluster_clients[2] + cluster_three_sts = statefulsets[cluster_three_client.cluster_name] + assert cluster_three_sts.status.ready_replicas == 2 + + +@pytest.mark.e2e_multi_cluster_replica_set_scale_up +def test_ops_manager_has_been_updated_correctly_after_scaling(): + ac = AutomationConfigTester() + ac.assert_processes_size(5) + + +@skip_if_local +@pytest.mark.e2e_multi_cluster_replica_set_scale_up +def test_replica_set_is_reachable(mongodb_multi: MongoDBMulti, ca_path: str): + tester = mongodb_multi.tester() + tester.assert_connectivity(opts=[with_tls(use_tls=True, ca_path=ca_path)]) diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_replica_set_test_mtls.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_replica_set_test_mtls.py new file mode 100644 index 000000000..f2a25b228 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_replica_set_test_mtls.py @@ -0,0 +1,230 @@ +from typing import List + +import kubernetes +import pytest + +from kubetester import wait_until, create_or_update +from kubetester.mongodb import Phase +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from kubetester.operator import Operator +from kubetester.kubetester import ( + fixture as yaml_fixture, + create_testing_namespace, + KubernetesTester, +) +from tests.multicluster.conftest import cluster_spec_list + + +@pytest.fixture(scope="module") +def mongodb_multi( + central_cluster_client: kubernetes.client.ApiClient, + namespace: str, + member_cluster_names: list[str], +) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml(yaml_fixture("mongodb-multi.yaml"), "multi-replica-set", namespace) + + resource["spec"]["clusterSpecList"] = cluster_spec_list(member_cluster_names, [2, 1, 2]) + + # TODO: incorporate this into the base class. + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + create_or_update(resource) + return resource + + +@pytest.mark.e2e_multi_cluster_mtls_test +def test_deploy_operator(multi_cluster_operator: Operator): + multi_cluster_operator.assert_is_running() + + +@pytest.mark.e2e_multi_cluster_mtls_test +def test_create_mongodb_multi(mongodb_multi: MongoDBMulti): + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=600) + + +@pytest.mark.e2e_multi_cluster_mtls_test +def test_create_mongo_pod_in_separate_namespace( + member_cluster_clients: List[MultiClusterClient], + evergreen_task_id: str, + namespace: str, +): + cluster_1_client = member_cluster_clients[0] + + # create the namespace to deploy the + create_testing_namespace(evergreen_task_id, f"{namespace}-mongo", api_client=cluster_1_client.api_client) + + corev1 = kubernetes.client.CoreV1Api(api_client=cluster_1_client.api_client) + + # def default_service_account_token_exists() -> bool: + # secrets: kubernetes.client.V1SecretList = corev1.list_namespaced_secret( + # f"{namespace}-mongo" + # ) + # for secret in secrets.items: + # if secret.metadata.name.startswith("default-token"): + # return True + # return False + # + # wait_until(default_service_account_token_exists, timeout=10) + + # create a pod with mongo installed in a separate namespace that does not have istio configured. + corev1.create_namespaced_pod( + f"{namespace}-mongo", + { + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "name": "mongo", + }, + "spec": { + "containers": [ + { + "image": "mongo", + "name": "mongo", + } + ], + "dnsPolicy": "ClusterFirst", + "restartPolicy": "Never", + }, + }, + ) + + def pod_is_ready() -> bool: + try: + pod = corev1.read_namespaced_pod("mongo", f"{namespace}-mongo") + return pod.status.phase == "Running" + except Exception: + return False + + wait_until(pod_is_ready, timeout=60) + + +@pytest.mark.e2e_multi_cluster_mtls_test +def test_connectivity_fails_from_second_namespace( + mongodb_multi: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], + namespace: str, +): + cluster_1_client = member_cluster_clients[0] + + service_fqdn = f"{mongodb_multi.name}-2-0-svc.{namespace}.svc.cluster.local" + cmd = ["mongosh", "--host", service_fqdn] + + result = KubernetesTester.run_command_in_pod_container( + "mongo", + f"{namespace}-mongo", + cmd, + container="mongo", + api_client=cluster_1_client.api_client, + ) + + failures = [ + "MongoServerSelectionError: connection to", + f"getaddrinfo ENOTFOUND {service_fqdn}", + "HostNotFound", + ] + + assert True in [ + failure in result for failure in failures + ], f"no expected failure messages found in result: {result}" + + +@pytest.mark.e2e_multi_cluster_mtls_test +def test_enable_istio_injection( + member_cluster_clients: List[MultiClusterClient], + namespace: str, +): + cluster_1_client = member_cluster_clients[0] + corev1 = kubernetes.client.CoreV1Api(api_client=cluster_1_client.api_client) + ns: kubernetes.client.V1Namespace = corev1.read_namespace(f"{namespace}-mongo") + ns.metadata.labels["istio-injection"] = "enabled" + corev1.patch_namespace(f"{namespace}-mongo", ns) + + +@pytest.mark.e2e_multi_cluster_mtls_test +def test_delete_existing_mongo_pod(member_cluster_clients: List[MultiClusterClient], namespace: str): + cluster_1_client = member_cluster_clients[0] + corev1 = kubernetes.client.CoreV1Api(api_client=cluster_1_client.api_client) + corev1.delete_namespaced_pod("mongo", f"{namespace}-mongo") + + def pod_is_deleted() -> bool: + try: + corev1.read_namespaced_pod("mongo", f"{namespace}-mongo") + return False + except kubernetes.client.ApiException: + return True + + wait_until(pod_is_deleted, timeout=120) + + +@pytest.mark.e2e_multi_cluster_mtls_test +def test_create_pod_with_istio_sidecar(member_cluster_clients: List[MultiClusterClient], namespace: str): + cluster_1_client = member_cluster_clients[0] + corev1 = kubernetes.client.CoreV1Api(api_client=cluster_1_client.api_client) + # create a pod with mongo installed in a separate namespace that does not have istio configured. + corev1.create_namespaced_pod( + f"{namespace}-mongo", + { + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "name": "mongo", + }, + "spec": { + "containers": [ + { + "image": "mongo", + "name": "mongo", + } + ], + "dnsPolicy": "ClusterFirst", + "restartPolicy": "Never", + }, + }, + ) + + def two_containers_are_present() -> bool: + try: + pod: kubernetes.client.V1Pod = corev1.read_namespaced_pod("mongo", f"{namespace}-mongo") + return len(pod.spec.containers) == 2 and pod.status.phase == "Running" + except Exception: + return False + + # wait for container to back up with sidecar + wait_until(two_containers_are_present, timeout=60) + + +@pytest.mark.e2e_multi_cluster_mtls_test +def test_connectivity_succeeds_from_second_namespace( + mongodb_multi: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], + namespace: str, +): + cluster_1_client = member_cluster_clients[0] + cmd = [ + "mongosh", + "--host", + f"{mongodb_multi.name}-0-0-svc.{namespace}.svc.cluster.local", + ] + + def can_connect_to_deployment() -> bool: + result = KubernetesTester.run_command_in_pod_container( + "mongo", + f"{namespace}-mongo", + cmd, + container="mongo", + api_client=cluster_1_client.api_client, + ) + if "Error: network error while attempting to run command 'isMaster' on host" in result: + return False + + if f"getaddrinfo ENOTFOUND" in result: + return False + + if "HostNotFound" in result: + return False + + if f"Connecting to: mongodb://{mongodb_multi.name}-0-0-svc.{namespace}.svc.cluster.local:27017" not in result: + return False + + return True + + wait_until(can_connect_to_deployment, timeout=60) diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_s3_based_backup_restore.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_s3_based_backup_restore.py new file mode 100644 index 000000000..5d4e18dde --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_s3_based_backup_restore.py @@ -0,0 +1,200 @@ +from typing import List + +import kubernetes +import kubernetes.client +from pytest import mark, fixture +from kubetester.awss3client import AwsS3Client, s3_endpoint + +from kubetester import ( + create_or_update, + create_or_update_configmap, +) +from kubetester import try_load + +from kubetester.certs import create_ops_manager_tls_certs +from kubetester.kubetester import ( + fixture as yaml_fixture, +) +from kubetester.mongodb import Phase +from kubetester.mongodb_multi import MongoDBMulti +from kubetester.operator import Operator +from kubetester.opsmanager import MongoDBOpsManager + +from .conftest import cluster_spec_list + +from tests.opsmanager.om_ops_manager_backup import ( + AWS_REGION, + create_aws_secret, + create_s3_bucket, +) + +TEST_DATA = {"name": "John", "address": "Highway 37", "age": 30} +MONGODB_PORT = 30000 + +S3_OPLOG_NAME = "s3-oplog" +S3_BLOCKSTORE_NAME = "s3-blockstore" +USER_PASSWORD = "/qwerty@!#:" + + +@fixture(scope="module") +def s3_bucket_oplog( + aws_s3_client: AwsS3Client, + namespace: str, + central_cluster_client: kubernetes.client.ApiClient, +) -> str: + create_aws_secret(aws_s3_client, S3_OPLOG_NAME + "-secret", namespace, central_cluster_client) + yield from create_s3_bucket(aws_s3_client) + + +@fixture(scope="module") +def s3_bucket_blockstore( + aws_s3_client: AwsS3Client, + namespace: str, + central_cluster_client: kubernetes.client.ApiClient, +) -> str: + create_aws_secret(aws_s3_client, S3_BLOCKSTORE_NAME + "-secret", namespace, central_cluster_client) + yield from create_s3_bucket(aws_s3_client) + + +@fixture(scope="module") +def ops_manager_certs( + namespace: str, + multi_cluster_issuer: str, + central_cluster_client: kubernetes.client.ApiClient, +): + return create_ops_manager_tls_certs( + multi_cluster_issuer, + namespace, + "om-backup", + secret_name="mdb-om-backup-cert", + # We need the interconnected certificate since we update coreDNS later with that ip -> domain + # because our central cluster is not part of the mesh, but we can access the pods via external IPs. + # Since we are using TLS we need a certificate for a hostname, an IP does not work, hence + # f"om-backup.{namespace}.interconnected" -> IP setup below + additional_domains=["fastdl.mongodb.org", f"om-backup.{namespace}.interconnected"], + api_client=central_cluster_client, + ) + + +def create_project_config_map(om: MongoDBOpsManager, mdb_name, project_name, client, custom_ca): + name = f"{mdb_name}-config" + data = { + "baseUrl": om.om_status().get_url(), + "projectName": project_name, + "sslMMSCAConfigMap": custom_ca, + "orgId": "", + } + + create_or_update_configmap(om.namespace, name, data, client) + + +@fixture(scope="module") +def multi_cluster_s3_replica_set( + ops_manager, + namespace, + member_cluster_names: List[str], + central_cluster_client: kubernetes.client.ApiClient, +) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml( + yaml_fixture("mongodb-multi-cluster.yaml"), "multi-replica-set", namespace + ).configure(ops_manager, "s3metadata", api_client=central_cluster_client) + + resource["spec"]["clusterSpecList"] = cluster_spec_list(member_cluster_names, [2, 1]) + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + yield create_or_update(resource) + + +@fixture(scope="module") +def ops_manager( + namespace: str, + multi_cluster_issuer_ca_configmap: str, + custom_appdb_version: str, + ops_manager_certs: str, + s3_bucket_oplog: str, + s3_bucket_blockstore: str, + central_cluster_client: kubernetes.client.ApiClient, +) -> MongoDBOpsManager: + + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_backup_tls_s3.yaml"), namespace=namespace + ) + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + + # resource["spec"]["externalConnectivity"] = {"type": "LoadBalancer"} + + resource.allow_mdb_rc_versions() + + del resource["spec"]["security"] + del resource["spec"]["applicationDatabase"]["security"] + + # configure S3 Blockstore + resource["spec"]["backup"]["s3Stores"][0]["name"] = S3_BLOCKSTORE_NAME + resource["spec"]["backup"]["s3Stores"][0]["s3SecretRef"]["name"] = S3_BLOCKSTORE_NAME + "-secret" + resource["spec"]["backup"]["s3Stores"][0]["s3BucketEndpoint"] = s3_endpoint(AWS_REGION) + resource["spec"]["backup"]["s3Stores"][0]["s3BucketName"] = s3_bucket_blockstore + resource["spec"]["backup"]["s3Stores"][0]["s3RegionOverride"] = AWS_REGION + + # configure S3 Oplog + resource["spec"]["backup"]["s3OpLogStores"][0]["name"] = S3_OPLOG_NAME + resource["spec"]["backup"]["s3OpLogStores"][0]["s3SecretRef"]["name"] = S3_OPLOG_NAME + "-secret" + resource["spec"]["backup"]["s3OpLogStores"][0]["s3BucketEndpoint"] = s3_endpoint(AWS_REGION) + resource["spec"]["backup"]["s3OpLogStores"][0]["s3BucketName"] = s3_bucket_oplog + resource["spec"]["backup"]["s3OpLogStores"][0]["s3RegionOverride"] = AWS_REGION + + resource.create_admin_secret(api_client=central_cluster_client) + + try_load(resource) + return resource + + +@mark.e2e_multi_cluster_s3_based_backup_restore +def test_deploy_operator(multi_cluster_operator: Operator): + multi_cluster_operator.assert_is_running() + + +@mark.e2e_multi_cluster_s3_based_backup_restore +class TestOpsManagerCreation: + """ + name: Ops Manager successful creation with backup and oplog stores enabled + description: | + Creates an Ops Manager instance with backup enabled. + """ + + def test_create_om( + self, + ops_manager: MongoDBOpsManager, + ): + ops_manager["spec"]["backup"]["members"] = 1 + create_or_update(ops_manager) + + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=600) + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=1000) + + def test_om_is_running(self, ops_manager: MongoDBOpsManager, central_cluster_client: kubernetes.client.ApiClient): + # at this point AppDB is used as the "metadatastore" + ops_manager.backup_status().assert_reaches_phase(Phase.Running, timeout=1000, ignore_errors=True) + om_tester = ops_manager.get_om_tester(api_client=central_cluster_client) + om_tester.assert_healthiness() + + def test_add_metadatastore( + self, + multi_cluster_s3_replica_set: MongoDBMulti, + ops_manager: MongoDBOpsManager, + ): + multi_cluster_s3_replica_set.assert_reaches_phase(Phase.Running, timeout=800) + + # configure metadatastore in om, use dedicate MDB instead of AppDB + ops_manager.load() + ops_manager["spec"]["backup"]["s3Stores"][0]["mongodbResourceRef"] = {"name": multi_cluster_s3_replica_set.name} + ops_manager["spec"]["backup"]["s3OpLogStores"][0]["mongodbResourceRef"] = { + "name": multi_cluster_s3_replica_set.name + } + ops_manager.update() + + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=10000) + ops_manager.backup_status().assert_reaches_phase(Phase.Running, timeout=1000, ignore_errors=True) + + def test_om_s3_stores(self, ops_manager: MongoDBOpsManager, central_cluster_client: kubernetes.client.ApiClient): + om_tester = ops_manager.get_om_tester(api_client=central_cluster_client) + om_tester.assert_s3_stores([{"id": S3_BLOCKSTORE_NAME, "s3RegionOverride": AWS_REGION}]) + om_tester.assert_oplog_s3_stores([{"id": S3_OPLOG_NAME, "s3RegionOverride": AWS_REGION}]) diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_scale_down_cluster.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_scale_down_cluster.py new file mode 100644 index 000000000..cc3cd35ff --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_scale_down_cluster.py @@ -0,0 +1,144 @@ +from typing import List + +import kubernetes +import pytest + +from kubetester.automation_config_tester import AutomationConfigTester +from kubetester.certs import create_multi_cluster_mongodb_tls_certs +from kubetester.mongodb import Phase +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from kubetester.mongotester import with_tls +from kubetester.operator import Operator +from kubetester.kubetester import ( + fixture as yaml_fixture, + skip_if_local, +) +from tests.multicluster.conftest import cluster_spec_list + +RESOURCE_NAME = "multi-replica-set" +BUNDLE_SECRET_NAME = f"prefix-{RESOURCE_NAME}-cert" + + +@pytest.fixture(scope="module") +def mongodb_multi_unmarshalled( + namespace: str, + multi_cluster_issuer_ca_configmap: str, + central_cluster_client: kubernetes.client.ApiClient, + member_cluster_names: list[str], +) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml(yaml_fixture("mongodb-multi.yaml"), RESOURCE_NAME, namespace) + resource["spec"]["clusterSpecList"] = cluster_spec_list(member_cluster_names, [2, 1, 2]) + resource["spec"]["security"] = { + "certsSecretPrefix": "prefix", + "tls": { + "ca": multi_cluster_issuer_ca_configmap, + }, + } + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + return resource + + +@pytest.fixture(scope="module") +def server_certs( + multi_cluster_issuer: str, + mongodb_multi_unmarshalled: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], + central_cluster_client: kubernetes.client.ApiClient, +): + + return create_multi_cluster_mongodb_tls_certs( + multi_cluster_issuer, + BUNDLE_SECRET_NAME, + member_cluster_clients, + central_cluster_client, + mongodb_multi_unmarshalled, + ) + + +@pytest.fixture(scope="module") +def mongodb_multi(mongodb_multi_unmarshalled: MongoDBMulti, server_certs: str) -> MongoDBMulti: + return mongodb_multi_unmarshalled.create() + + +@pytest.mark.e2e_multi_cluster_scale_down_cluster +def test_deploy_operator(multi_cluster_operator: Operator): + multi_cluster_operator.assert_is_running() + + +@pytest.mark.e2e_multi_cluster_scale_down_cluster +def test_create_mongodb_multi(mongodb_multi: MongoDBMulti): + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=1200) + + +@pytest.mark.e2e_multi_cluster_scale_down_cluster +def test_statefulsets_have_been_created_correctly( + mongodb_multi: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], +): + statefulsets = mongodb_multi.read_statefulsets(member_cluster_clients) + + assert len(statefulsets) == 3 + + cluster_one_client = member_cluster_clients[0] + cluster_one_sts = statefulsets[cluster_one_client.cluster_name] + assert cluster_one_sts.status.ready_replicas == 2 + + cluster_two_client = member_cluster_clients[1] + cluster_two_sts = statefulsets[cluster_two_client.cluster_name] + assert cluster_two_sts.status.ready_replicas == 1 + + cluster_three_client = member_cluster_clients[2] + cluster_three_sts = statefulsets[cluster_three_client.cluster_name] + assert cluster_three_sts.status.ready_replicas == 2 + + +@pytest.mark.e2e_multi_cluster_scale_down_cluster +def test_ops_manager_has_been_updated_correctly_before_scaling(): + ac = AutomationConfigTester() + ac.assert_processes_size(5) + + +@pytest.mark.e2e_multi_cluster_scale_down_cluster +def test_scale_mongodb_multi(mongodb_multi: MongoDBMulti): + mongodb_multi.load() + # remove first and last cluster + mongodb_multi["spec"]["clusterSpecList"] = [mongodb_multi["spec"]["clusterSpecList"][1]] + mongodb_multi.update() + + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=1800, ignore_errors=True) + + +@pytest.mark.e2e_multi_cluster_scale_down_cluster +def test_statefulsets_have_been_scaled_down_correctly( + mongodb_multi: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], +): + statefulsets = mongodb_multi.read_statefulsets([member_cluster_clients[1]]) + + with pytest.raises(kubernetes.client.exceptions.ApiException) as e: + mongodb_multi.read_statefulsets([member_cluster_clients[0]]) + assert e.value.reason == "Not Found" + + # there should only be one statefulset in the second cluster + cluster_two_client = member_cluster_clients[1] + cluster_two_sts = statefulsets[cluster_two_client.cluster_name] + assert cluster_two_sts.status.ready_replicas == 1 + + # there should be no statefulsets in the last cluster + with pytest.raises(kubernetes.client.exceptions.ApiException) as e: + mongodb_multi.read_statefulsets([member_cluster_clients[2]]) + assert e.value.reason == "Not Found" + + +@pytest.mark.e2e_multi_cluster_scale_down_cluster +def test_ops_manager_has_been_updated_correctly_after_scaling(): + ac = AutomationConfigTester() + ac.assert_processes_size(1) + + +@skip_if_local +@pytest.mark.e2e_multi_cluster_scale_down_cluster +def test_replica_set_is_reachable(mongodb_multi: MongoDBMulti, ca_path: str): + # there should only be one member in cluster 2 so there is just a single service. + tester = mongodb_multi.tester(service_names=[f"{mongodb_multi.name}-1-0-svc"]) + tester.assert_connectivity(opts=[with_tls(use_tls=True, ca_path=ca_path)]) diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_scale_up_cluster.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_scale_up_cluster.py new file mode 100644 index 000000000..74d315f02 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_scale_up_cluster.py @@ -0,0 +1,140 @@ +from typing import List + +import kubernetes +import pytest + +from kubetester.automation_config_tester import AutomationConfigTester +from kubetester.certs import create_multi_cluster_mongodb_tls_certs +from kubetester.mongodb import Phase +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from kubetester.mongotester import with_tls +from kubetester.operator import Operator +from kubetester.kubetester import ( + fixture as yaml_fixture, + skip_if_local, +) +from tests.multicluster.conftest import cluster_spec_list + +RESOURCE_NAME = "multi-replica-set" +BUNDLE_SECRET_NAME = f"prefix-{RESOURCE_NAME}-cert" + + +@pytest.fixture(scope="module") +def mongodb_multi_unmarshalled( + namespace: str, + multi_cluster_issuer_ca_configmap: str, + central_cluster_client: kubernetes.client.ApiClient, + member_cluster_names: list[str], +) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml(yaml_fixture("mongodb-multi.yaml"), RESOURCE_NAME, namespace) + # ensure certs are created for the members during scale up + resource["spec"]["clusterSpecList"] = cluster_spec_list(member_cluster_names, [2, 1, 2]) + resource["spec"]["security"] = { + "certsSecretPrefix": "prefix", + "tls": { + "ca": multi_cluster_issuer_ca_configmap, + }, + } + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + return resource + + +@pytest.fixture(scope="module") +def server_certs( + multi_cluster_issuer: str, + mongodb_multi_unmarshalled: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], + central_cluster_client: kubernetes.client.ApiClient, +): + + return create_multi_cluster_mongodb_tls_certs( + multi_cluster_issuer, + BUNDLE_SECRET_NAME, + member_cluster_clients, + central_cluster_client, + mongodb_multi_unmarshalled, + ) + + +@pytest.fixture(scope="module") +def mongodb_multi(mongodb_multi_unmarshalled: MongoDBMulti, server_certs: str) -> MongoDBMulti: + # remove the last element, we are only starting with 2 clusters we will scale up the 3rd one later. + mongodb_multi_unmarshalled["spec"]["clusterSpecList"].pop() + return mongodb_multi_unmarshalled.create() + + +@pytest.mark.e2e_multi_cluster_scale_up_cluster +def test_deploy_operator(multi_cluster_operator: Operator): + multi_cluster_operator.assert_is_running() + + +@pytest.mark.e2e_multi_cluster_scale_up_cluster +def test_create_mongodb_multi(mongodb_multi: MongoDBMulti): + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=600) + + +@pytest.mark.e2e_multi_cluster_scale_up_cluster +def test_statefulsets_have_been_created_correctly( + mongodb_multi: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], +): + # read all statefulsets except the last one + statefulsets = mongodb_multi.read_statefulsets(member_cluster_clients[:-1]) + cluster_one_client = member_cluster_clients[0] + cluster_one_sts = statefulsets[cluster_one_client.cluster_name] + assert cluster_one_sts.status.ready_replicas == 2 + + cluster_two_client = member_cluster_clients[1] + cluster_two_sts = statefulsets[cluster_two_client.cluster_name] + assert cluster_two_sts.status.ready_replicas == 1 + + +@pytest.mark.e2e_multi_cluster_scale_up_cluster +def test_ops_manager_has_been_updated_correctly_before_scaling(): + ac = AutomationConfigTester() + ac.assert_processes_size(3) + + +@pytest.mark.e2e_multi_cluster_scale_up_cluster +def test_scale_mongodb_multi(mongodb_multi: MongoDBMulti, member_cluster_clients: List[MultiClusterClient]): + mongodb_multi.load() + mongodb_multi["spec"]["clusterSpecList"].append( + {"members": 2, "clusterName": member_cluster_clients[2].cluster_name} + ) + mongodb_multi.update() + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=1800) + + +@pytest.mark.e2e_multi_cluster_scale_up_cluster +def test_statefulsets_have_been_scaled_up_correctly( + mongodb_multi: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], +): + statefulsets = mongodb_multi.read_statefulsets(member_cluster_clients) + + assert len(statefulsets) == 3 + + cluster_one_client = member_cluster_clients[0] + cluster_one_sts = statefulsets[cluster_one_client.cluster_name] + assert cluster_one_sts.status.ready_replicas == 2 + + cluster_two_client = member_cluster_clients[1] + cluster_two_sts = statefulsets[cluster_two_client.cluster_name] + assert cluster_two_sts.status.ready_replicas == 1 + + cluster_three_client = member_cluster_clients[2] + cluster_three_sts = statefulsets[cluster_three_client.cluster_name] + assert cluster_three_sts.status.ready_replicas == 2 + + +@pytest.mark.e2e_multi_cluster_scale_up_cluster +def test_ops_manager_has_been_updated_correctly_after_scaling(): + ac = AutomationConfigTester() + ac.assert_processes_size(5) + + +@skip_if_local +@pytest.mark.e2e_multi_cluster_scale_up_cluster +def test_replica_set_is_reachable(mongodb_multi: MongoDBMulti, ca_path: str): + tester = mongodb_multi.tester() + tester.assert_connectivity(opts=[with_tls(use_tls=True, ca_path=ca_path)]) diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_scale_up_cluster_new_cluster.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_scale_up_cluster_new_cluster.py new file mode 100644 index 000000000..695cadb6f --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_scale_up_cluster_new_cluster.py @@ -0,0 +1,175 @@ +from typing import List, Callable, Dict + +import kubernetes +import pytest +from kubernetes import client + +from kubetester.automation_config_tester import AutomationConfigTester +from kubetester.certs import create_multi_cluster_mongodb_tls_certs +from kubetester.mongodb import Phase +from kubetester.mongodb_multi import ( + MongoDBMulti, + MultiClusterClient, +) +from kubetester.mongotester import with_tls +from kubetester.operator import Operator +from kubetester.kubetester import ( + fixture as yaml_fixture, + skip_if_local, +) +from tests.conftest import run_kube_config_creation_tool, MULTI_CLUSTER_OPERATOR_NAME +from tests.multicluster.conftest import cluster_spec_list + +RESOURCE_NAME = "multi-replica-set" +BUNDLE_SECRET_NAME = f"prefix-{RESOURCE_NAME}-cert" + + +@pytest.fixture(scope="module") +def mongodb_multi_unmarshalled( + namespace: str, + multi_cluster_issuer_ca_configmap: str, + central_cluster_client: kubernetes.client.ApiClient, + member_cluster_names: list[str], +) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml(yaml_fixture("mongodb-multi.yaml"), RESOURCE_NAME, namespace) + # ensure certs are created for the members during scale up + resource["spec"]["clusterSpecList"] = cluster_spec_list(member_cluster_names, [2, 1, 2]) + + resource["spec"]["security"] = { + "certsSecretPrefix": "prefix", + "tls": { + "ca": multi_cluster_issuer_ca_configmap, + }, + } + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + return resource + + +@pytest.fixture(scope="module") +def server_certs( + multi_cluster_issuer: str, + mongodb_multi_unmarshalled: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], + central_cluster_client: kubernetes.client.ApiClient, +): + return create_multi_cluster_mongodb_tls_certs( + multi_cluster_issuer, + BUNDLE_SECRET_NAME, + member_cluster_clients, + central_cluster_client, + mongodb_multi_unmarshalled, + ) + + +@pytest.fixture(scope="module") +def mongodb_multi(mongodb_multi_unmarshalled: MongoDBMulti, server_certs: str) -> MongoDBMulti: + mongodb_multi_unmarshalled["spec"]["clusterSpecList"].pop() + return mongodb_multi_unmarshalled.create() + + +@pytest.mark.e2e_multi_cluster_scale_up_cluster_new_cluster +def test_deploy_operator( + install_multi_cluster_operator_set_members_fn: Callable[[List[str]], Operator], + member_cluster_names: List[str], + namespace: str, +): + run_kube_config_creation_tool(member_cluster_names[:-1], namespace, namespace, member_cluster_names) + # deploy the operator without the final cluster + operator = install_multi_cluster_operator_set_members_fn(member_cluster_names[:-1]) + operator.assert_is_running() + + +@pytest.mark.e2e_multi_cluster_scale_up_cluster_new_cluster +def test_create_mongodb_multi(mongodb_multi: MongoDBMulti): + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=1200) + + +@pytest.mark.e2e_multi_cluster_scale_up_cluster_new_cluster +def test_statefulsets_have_been_created_correctly( + mongodb_multi: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], +): + clients = {c.cluster_name: c for c in member_cluster_clients} + + # read all statefulsets except the last one + statefulsets = mongodb_multi.read_statefulsets(member_cluster_clients[:-1]) + cluster_one_client = clients["kind-e2e-cluster-1"] + cluster_one_sts = statefulsets[cluster_one_client.cluster_name] + assert cluster_one_sts.status.ready_replicas == 2 + + cluster_two_client = clients["kind-e2e-cluster-2"] + cluster_two_sts = statefulsets[cluster_two_client.cluster_name] + assert cluster_two_sts.status.ready_replicas == 1 + + +@pytest.mark.e2e_multi_cluster_scale_up_cluster_new_cluster +def test_ops_manager_has_been_updated_correctly_before_scaling(): + ac = AutomationConfigTester() + ac.assert_processes_size(3) + + +@pytest.mark.e2e_multi_cluster_scale_up_cluster_new_cluster +def test_delete_deployment(namespace: str, central_cluster_client: kubernetes.client.ApiClient): + client.AppsV1Api(api_client=central_cluster_client).delete_namespaced_deployment( + MULTI_CLUSTER_OPERATOR_NAME, namespace + ) + + +@pytest.mark.e2e_multi_cluster_scale_up_cluster_new_cluster +def test_re_deploy_operator( + install_multi_cluster_operator_set_members_fn: Callable[[List[str]], Operator], + member_cluster_names: List[str], + namespace: str, +): + run_kube_config_creation_tool(member_cluster_names, namespace, namespace, member_cluster_names) + + # deploy the operator without all clusters + operator = install_multi_cluster_operator_set_members_fn(member_cluster_names) + operator.assert_is_running() + + +@pytest.mark.e2e_multi_cluster_scale_up_cluster_new_cluster +def test_add_new_cluster_to_mongodb_multi_resource( + mongodb_multi: MongoDBMulti, member_cluster_clients: List[MultiClusterClient] +): + mongodb_multi.load() + mongodb_multi["spec"]["clusterSpecList"].append( + {"members": 2, "clusterName": member_cluster_clients[-1].cluster_name} + ) + mongodb_multi.update() + mongodb_multi.assert_abandons_phase(Phase.Running, timeout=60) + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=800) + + +@pytest.mark.e2e_multi_cluster_scale_up_cluster_new_cluster +def test_statefulsets_have_been_created_correctly_after_cluster_addition( + mongodb_multi: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], +): + clients = {c.cluster_name: c for c in member_cluster_clients} + # read all statefulsets except the last one + statefulsets = mongodb_multi.read_statefulsets(member_cluster_clients) + cluster_one_client = clients["kind-e2e-cluster-1"] + cluster_one_sts = statefulsets[cluster_one_client.cluster_name] + assert cluster_one_sts.status.ready_replicas == 2 + + cluster_two_client = clients["kind-e2e-cluster-2"] + cluster_two_sts = statefulsets[cluster_two_client.cluster_name] + assert cluster_two_sts.status.ready_replicas == 1 + + cluster_three_client = clients["kind-e2e-cluster-3"] + cluster_three_sts = statefulsets[cluster_three_client.cluster_name] + assert cluster_three_sts.status.ready_replicas == 2 + + +@pytest.mark.e2e_multi_cluster_scale_up_cluster_new_cluster +def test_ops_manager_has_been_updated_correctly_after_scaling(): + ac = AutomationConfigTester() + ac.assert_processes_size(5) + + +@skip_if_local +@pytest.mark.e2e_multi_cluster_scale_up_cluster_new_cluster +def test_replica_set_is_reachable(mongodb_multi: MongoDBMulti, ca_path: str): + tester = mongodb_multi.tester() + tester.assert_connectivity(opts=[with_tls(use_tls=True, ca_path=ca_path)]) diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_scram.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_scram.py new file mode 100644 index 000000000..d2d3d04d1 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_scram.py @@ -0,0 +1,222 @@ +from typing import List + +import kubernetes +import pytest + +from kubetester import ( + read_secret, + create_or_update, + create_or_update_secret, + update_secret, +) +from kubetester.automation_config_tester import AutomationConfigTester +from kubetester.kubetester import ( + KubernetesTester, + fixture as yaml_fixture, + skip_if_local, +) +from kubetester.mongodb import Phase +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from kubetester.mongodb_user import MongoDBUser +from kubetester.mongotester import with_scram +from kubetester.operator import Operator +from tests.multicluster.conftest import cluster_spec_list + +MDB_RESOURCE = "multi-replica-set-scram" +USER_NAME = "my-user-1" +USER_RESOURCE = "multi-replica-set-scram-user" +USER_DATABASE = "admin" +PASSWORD_SECRET_NAME = "mms-user-1-password" +USER_PASSWORD = "my-password" +NEW_USER_PASSWORD = "my-new-password7" + + +@pytest.fixture(scope="function") +def mongodb_multi( + central_cluster_client: kubernetes.client.ApiClient, + namespace: str, + member_cluster_names, +) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml(yaml_fixture("mongodb-multi.yaml"), MDB_RESOURCE, namespace) + + resource["spec"]["security"] = { + "authentication": { + "agents": {"mode": "MONGODB-CR"}, + "enabled": True, + "modes": ["SCRAM-SHA-1", "SCRAM-SHA-256", "MONGODB-CR"], + } + } + + resource["spec"]["clusterSpecList"] = cluster_spec_list(member_cluster_names, [2, 1, 2]) + + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + + return resource + + +@pytest.fixture(scope="function") +def mongodb_user(central_cluster_client: kubernetes.client.ApiClient, namespace: str) -> MongoDBUser: + resource = MongoDBUser.from_yaml(yaml_fixture("scram-sha-user.yaml"), USER_RESOURCE, namespace) + + resource["spec"]["username"] = USER_NAME + resource["spec"]["passwordSecretKeyRef"] = { + "name": PASSWORD_SECRET_NAME, + "key": "password", + } + resource["spec"]["mongodbResourceRef"]["name"] = MDB_RESOURCE + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + + return resource + + +@pytest.mark.e2e_multi_cluster_scram +def test_deploy_operator(multi_cluster_operator: Operator): + multi_cluster_operator.assert_is_running() + + +@pytest.mark.e2e_multi_cluster_scram +def test_create_mongodb_user( + central_cluster_client: kubernetes.client.ApiClient, + mongodb_user: MongoDBUser, + namespace: str, +): + # create user secret first + create_or_update_secret( + namespace=namespace, + name=PASSWORD_SECRET_NAME, + data={"password": USER_PASSWORD}, + api_client=central_cluster_client, + ) + create_or_update(mongodb_user) + mongodb_user.assert_reaches_phase(Phase.Pending, timeout=100) + + +@pytest.mark.e2e_multi_cluster_scram +def test_create_mongodb_multi_with_scram(mongodb_multi: MongoDBMulti): + create_or_update(mongodb_multi) + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=800) + + +@pytest.mark.e2e_multi_cluster_scram +def test_user_reaches_updated( + central_cluster_client: kubernetes.client.ApiClient, + mongodb_user: MongoDBUser, +): + mongodb_user.assert_reaches_phase(Phase.Updated, timeout=100) + + +@pytest.mark.e2e_multi_cluster_scram +def test_replica_set_connectivity_using_user_password(mongodb_multi: MongoDBMulti): + tester = mongodb_multi.tester() + tester.assert_connectivity(db="admin", opts=[with_scram(USER_NAME, USER_PASSWORD)]) + + +@pytest.mark.e2e_multi_cluster_scram +def test_change_password_and_check_connectivity( + namespace: str, + mongodb_multi: MongoDBMulti, + central_cluster_client: kubernetes.client.ApiClient, +): + create_or_update_secret( + namespace, + PASSWORD_SECRET_NAME, + {"password": NEW_USER_PASSWORD}, + api_client=central_cluster_client, + ) + tester = mongodb_multi.tester() + tester.assert_scram_sha_authentication( + password=NEW_USER_PASSWORD, + username=USER_NAME, + auth_mechanism="SCRAM-SHA-256", + ) + + +@pytest.mark.e2e_multi_cluster_scram +def test_user_cannot_authenticate_with_old_password(mongodb_multi: MongoDBMulti): + tester = mongodb_multi.tester() + tester.assert_scram_sha_authentication_fails( + password=USER_PASSWORD, + username=USER_NAME, + auth_mechanism="SCRAM-SHA-256", + ) + + +@pytest.mark.e2e_multi_cluster_scram +def test_connection_string_secret_was_created( + namespace: str, + mongodb_multi: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], +): + for client in member_cluster_clients: + secret_data = read_secret( + namespace, + f"{mongodb_multi.name}-{USER_RESOURCE}-{USER_DATABASE}", + api_client=client.api_client, + ) + assert "username" in secret_data + assert "password" in secret_data + assert "connectionString.standard" in secret_data + assert "connectionString.standardSrv" in secret_data + + +@pytest.mark.e2e_multi_cluster_scram +def test_om_configured_correctly(): + expected_roles = { + ("admin", "clusterAdmin"), + ("admin", "userAdminAnyDatabase"), + ("admin", "readWrite"), + ("admin", "userAdminAnyDatabase"), + } + tester = AutomationConfigTester(KubernetesTester.get_automation_config()) + tester.assert_has_user(USER_NAME) + tester.assert_user_has_roles(USER_NAME, expected_roles) + tester.assert_authentication_enabled(expected_num_deployment_auth_mechanisms=3) + tester.assert_authentication_mechanism_enabled("SCRAM-SHA-256", active_auth_mechanism=False) + tester.assert_authentication_mechanism_enabled("SCRAM-SHA-1", active_auth_mechanism=False) + tester.assert_authentication_mechanism_enabled("MONGODB-CR", active_auth_mechanism=False) + + +@pytest.mark.e2e_multi_cluster_scram +def test_replica_set_connectivity(mongodb_multi: MongoDBMulti): + tester = mongodb_multi.tester() + tester.assert_connectivity(db="admin", opts=[with_scram(USER_NAME, NEW_USER_PASSWORD)]) + + +@pytest.mark.e2e_multi_cluster_scram +def test_replica_set_connectivity_from_connection_string_standard( + namespace: str, + mongodb_multi: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], +): + secret_data = read_secret( + namespace, + f"{mongodb_multi.name}-{USER_RESOURCE}-{USER_DATABASE}", + api_client=member_cluster_clients[-1].api_client, + ) + tester = mongodb_multi.tester() + tester.cnx_string = secret_data["connectionString.standard"] + tester.assert_connectivity( + db="admin", + opts=[with_scram(USER_NAME, NEW_USER_PASSWORD)], + ) + + +@pytest.mark.e2e_multi_cluster_scram +def test_replica_set_connectivity_from_connection_string_standard_srv( + namespace: str, + mongodb_multi: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], +): + secret_data = read_secret( + namespace, + f"{mongodb_multi.name}-{USER_RESOURCE}-{USER_DATABASE}", + api_client=member_cluster_clients[-1].api_client, + ) + tester = mongodb_multi.tester() + tester.cnx_string = secret_data["connectionString.standardSrv"] + tester.assert_connectivity( + db="admin", + opts=[ + with_scram(USER_NAME, NEW_USER_PASSWORD), + ], + ) diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_split_horizon.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_split_horizon.py new file mode 100644 index 000000000..2f67f913c --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_split_horizon.py @@ -0,0 +1,148 @@ +import yaml +from kubernetes import client +from pytest import mark, fixture +from typing import List +from kubetester import read_secret, create_service +from kubetester.certs import create_multi_cluster_mongodb_tls_certs +import kubernetes +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from kubetester.kubetester import skip_if_local, KubernetesTester +from kubetester.mongotester import with_tls +from kubetester.operator import Operator +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.mongodb import Phase + +CERT_SECRET_PREFIX = "clustercert" +MDB_RESOURCE = "multi-cluster-replica-set" +BUNDLE_SECRET_NAME = f"{CERT_SECRET_PREFIX}-{MDB_RESOURCE}-cert" +USER_NAME = "my-user-1" +PASSWORD_SECRET_NAME = "mms-user-1-password" +USER_PASSWORD = "my-password" + +# This test will set up an environment which will configure a resource with split horizon enabled. +# Steps to run this test. + +# 1. Change the nodenames under "additional_domains" +# 2. Run this test with: `make e2e test=e2e_multi_cluster_split_horizon light=true local=true`. +# 3. Wait for the test to pass (this means the environment is set up.) +# 4. Exec into any database pod and note the contents of the files referenced by the fields +# * net.tls.certificateKeyFile +# * net.tlsCAFile +# from the /data/automation-mongod.conf file. + +# 5. Test the connection +# Testing the connection can be done from either the worker node or from your local machine(note accessing traffic from a pod inside the cluster would work irrespective SH is configured correctly or not) +# 1. Acsessing from worker node +# * ssh into any worker node +# * Install the mongo shell +# * Create files from the two files mentioned above. (server.pem and ca.crt) +# * Run "mongo "mongodb://${WORKER_NODE}:30100,${WORKER_NODE}:30101,${WORKER_NODE}:30102/?replicaSet=test-tls-base-rs-external-access" --tls --tlsCertificateKeyFile server.pem --tlsCAFile ca.crt" +# 2. Accessing from local machine +# * Install the mongo shell +# * Create files from the two files mentioned above. (server.pem and ca.crt) +# * Open access to KOPS nodes from your local machine by following these steps(by default KOPS doesn't expose traffic from all ports to the internet) +# : https://stackoverflow.com/questions/45543694/kubernetes-cluster-on-aws-with-kops-nodeport-service-unavailable/45561848#45561848 +# * Run "mongo "mongodb://${WORKER_NODE1}:30100,${WORKER_NODE2}:30101,${WORKER_NODE3}:30102/?replicaSet=test-tls-base-rs-external-access" --tls --tlsCertificateKeyFile server.pem --tlsCAFile ca.crt" +# When split horizon is not configured, specifying the replicaset name should fail. +# When split horizon is configured, it will successfully connect to the primary. + +# Example: mongo "mongodb://ec2-35-178-71-70.eu-west-2.compute.amazonaws.com:30100,ec2-52-56-69-123.eu-west-2.compute.amazonaws.com:30100,ec2-3-10-22-163.eu-west-2.compute.amazonaws.com:30100" --tls --tlsCertificateKeyFile server.pem --tlsCAFile ca-pem + +# 6. Clean the namespace +# * This test creates node ports, which we should delete. + + +@fixture(scope="module") +def mongodb_multi_unmarshalled(namespace: str) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml(yaml_fixture("mongodb-multi-split-horizon.yaml"), MDB_RESOURCE, namespace) + return resource + + +@fixture(scope="module") +def server_certs( + multi_cluster_issuer: str, + mongodb_multi_unmarshalled: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], + central_cluster_client: kubernetes.client.ApiClient, +): + + return create_multi_cluster_mongodb_tls_certs( + multi_cluster_issuer, + BUNDLE_SECRET_NAME, + member_cluster_clients, + central_cluster_client, + mongodb_multi_unmarshalled, + additional_domains=[ + "*", + "ec2-35-178-71-70.eu-west-2.compute.amazonaws.com", + "ec2-52-56-69-123.eu-west-2.compute.amazonaws.com", + "ec2-3-10-22-163.eu-west-2.compute.amazonaws.com", + ], + ) + + +@fixture(scope="module") +def mongodb_multi( + central_cluster_client: kubernetes.client.ApiClient, + server_certs: str, + mongodb_multi_unmarshalled: MongoDBMulti, + multi_cluster_issuer_ca_configmap: str, +) -> MongoDBMulti: + + resource = mongodb_multi_unmarshalled + resource["spec"]["security"] = { + "certsSecretPrefix": CERT_SECRET_PREFIX, + "tls": { + "enabled": True, + "ca": multi_cluster_issuer_ca_configmap, + }, + } + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + return resource.create() + + +@mark.e2e_multi_cluster_split_horizon +def test_deploy_operator(multi_cluster_operator: Operator): + multi_cluster_operator.assert_is_running() + + +@mark.e2e_multi_cluster_split_horizon +def test_deploy_mongodb_multi_with_tls( + mongodb_multi: MongoDBMulti, + namespace: str, +): + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=1200) + + +@mark.e2e_multi_cluster_split_horizon +def test_create_node_ports(mongodb_multi: MongoDBMulti, member_cluster_clients: List[MultiClusterClient]): + for mcc in member_cluster_clients: + with open( + yaml_fixture(f"split-horizon-node-ports/split-horizon-node-port.yaml"), + "r", + ) as f: + service_body = yaml.safe_load(f.read()) + + # configure labels and selectors + service_body["metadata"]["labels"][ + "mongodbmulticluster" + ] = f"{mongodb_multi.namespace}-{mongodb_multi.name}" + service_body["metadata"]["labels"][ + "statefulset.kubernetes.io/pod-name" + ] = f"{mongodb_multi.name}-{mcc.cluster_index}-0" + service_body["spec"]["selector"][ + "statefulset.kubernetes.io/pod-name" + ] = f"{mongodb_multi.name}-{mcc.cluster_index}-0" + + KubernetesTester.create_service( + mongodb_multi.namespace, + body=service_body, + api_client=mcc.api_client, + ) + + +@skip_if_local +@mark.e2e_multi_cluster_split_horizon +def test_tls_connectivity(mongodb_multi: MongoDBMulti, ca_path: str): + tester = mongodb_multi.tester() + tester.assert_connectivity(opts=[with_tls(use_tls=True, ca_path=ca_path)]) diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_sts_override.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_sts_override.py new file mode 100644 index 000000000..360441167 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_sts_override.py @@ -0,0 +1,68 @@ +from typing import List + +import kubernetes +import pytest +from kubernetes import client + +from kubetester import create_or_update +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.mongodb import Phase +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from kubetester.operator import Operator + + +@pytest.fixture(scope="module") +def mongodb_multi( + central_cluster_client: kubernetes.client.ApiClient, namespace: str, member_cluster_names: list[str] +) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml( + yaml_fixture("mongodb-multi-sts-override.yaml"), + "multi-replica-set-sts-override", + namespace, + ) + + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + return create_or_update(resource) + + +@pytest.mark.e2e_multi_sts_override +def test_deploy_operator(multi_cluster_operator: Operator): + multi_cluster_operator.assert_is_running() + + +@pytest.mark.e2e_multi_sts_override +def test_create_mongodb_multi(mongodb_multi: MongoDBMulti): + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=1200) + + +@pytest.mark.e2e_multi_sts_override +def test_statefulset_overrides(mongodb_multi: MongoDBMulti, member_cluster_clients: List[MultiClusterClient]): + statefulsets = mongodb_multi.read_statefulsets(member_cluster_clients) + + # assert sts.podspec override in cluster1 + cluster_one_client = member_cluster_clients[0] + cluster_one_sts = statefulsets[cluster_one_client.cluster_name] + assert_container_in_sts("sidecar1", cluster_one_sts) + + # assert sts.podspec override in cluster2 + cluster_two_client = member_cluster_clients[1] + cluster_two_sts = statefulsets[cluster_two_client.cluster_name] + assert_container_in_sts("sidecar2", cluster_two_sts) + + +@pytest.mark.e2e_multi_sts_override +def test_access_modes_pvc( + mongodb_multi: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], + namespace: str, +): + pvc = client.CoreV1Api(api_client=member_cluster_clients[0].api_client).read_namespaced_persistent_volume_claim( + f"data-{mongodb_multi.name}-{0}-{0}", namespace + ) + + assert "ReadWriteOnce" in pvc.spec.access_modes + + +def assert_container_in_sts(container_name: str, sts: client.V1StatefulSet): + container_names = [c.name for c in sts.spec.template.spec.containers] + assert container_name in container_names diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_tls_no_mesh.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_tls_no_mesh.py new file mode 100644 index 000000000..d65ab2f68 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_tls_no_mesh.py @@ -0,0 +1,246 @@ +from typing import List + +import kubernetes +from kubernetes import client +from pytest import mark, fixture + +from kubetester import create_or_update, get_service +from kubetester.certs import create_multi_cluster_mongodb_tls_certs +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.mongodb import Phase +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from kubetester.operator import Operator +from tests.conftest import update_coredns_hosts +from tests.multicluster.conftest import cluster_spec_list + +CERT_SECRET_PREFIX = "clustercert" +MDB_RESOURCE = "multi-cluster-replica-set" +BUNDLE_SECRET_NAME = f"{CERT_SECRET_PREFIX}-{MDB_RESOURCE}-cert" +BUNDLE_PEM_SECRET_NAME = f"{CERT_SECRET_PREFIX}-{MDB_RESOURCE}-cert-pem" + + +@fixture(scope="module") +def mongodb_multi_unmarshalled(namespace: str, member_cluster_names: List[str]) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml(yaml_fixture("mongodb-multi.yaml"), MDB_RESOURCE, namespace) + resource["spec"]["persistent"] = False + # These domains map 1:1 to the CoreDNS file. Please be mindful when updating them. + resource["spec"]["clusterSpecList"] = cluster_spec_list(member_cluster_names, [2, 2, 2]) + + resource["spec"]["externalAccess"] = {} + resource["spec"]["clusterSpecList"][0]["externalAccess"] = { + "externalDomain": "kind-e2e-cluster-1.interconnected", + "externalService": { + "spec": { + "type": "LoadBalancer", + "publishNotReadyAddresses": False, + "ports": [ + { + "name": "mongodb", + "port": 27017, + }, + { + "name": "backup", + "port": 27018, + }, + { + "name": "testing0", + "port": 27019, + }, + ], + } + }, + } + resource["spec"]["clusterSpecList"][1]["externalAccess"] = { + "externalDomain": "kind-e2e-cluster-2.interconnected", + "externalService": { + "spec": { + "type": "LoadBalancer", + "publishNotReadyAddresses": False, + "ports": [ + { + "name": "mongodb", + "port": 27017, + }, + { + "name": "backup", + "port": 27018, + }, + { + "name": "testing1", + "port": 27019, + }, + ], + } + }, + } + resource["spec"]["clusterSpecList"][2]["externalAccess"] = { + "externalDomain": "kind-e2e-cluster-3.interconnected", + "externalService": { + "spec": { + "type": "LoadBalancer", + "publishNotReadyAddresses": False, + "ports": [ + { + "name": "mongodb", + "port": 27017, + }, + { + "name": "backup", + "port": 27018, + }, + { + "name": "testing2", + "port": 27019, + }, + ], + } + }, + } + + return resource + + +@fixture(scope="module") +def disable_istio( + multi_cluster_operator: Operator, + namespace: str, + member_cluster_clients: List[MultiClusterClient], +): + for mcc in member_cluster_clients: + api = client.CoreV1Api(api_client=mcc.api_client) + labels = {"istio-injection": "disabled"} + ns = api.read_namespace(name=namespace) + ns.metadata.labels.update(labels) + api.replace_namespace(name=namespace, body=ns) + return None + + +@fixture(scope="module") +def mongodb_multi( + central_cluster_client: kubernetes.client.ApiClient, + disable_istio, + namespace: str, + mongodb_multi_unmarshalled: MongoDBMulti, + multi_cluster_issuer_ca_configmap: str, +) -> MongoDBMulti: + mongodb_multi_unmarshalled["spec"]["security"] = { + "certsSecretPrefix": CERT_SECRET_PREFIX, + "tls": { + "ca": multi_cluster_issuer_ca_configmap, + }, + } + mongodb_multi_unmarshalled.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + + return create_or_update(mongodb_multi_unmarshalled) + + +@fixture(scope="module") +def server_certs( + multi_cluster_issuer: str, + mongodb_multi_unmarshalled: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], + central_cluster_client: kubernetes.client.ApiClient, +): + return create_multi_cluster_mongodb_tls_certs( + multi_cluster_issuer, + BUNDLE_SECRET_NAME, + member_cluster_clients, + central_cluster_client, + mongodb_multi_unmarshalled, + ) + + +@mark.e2e_multi_cluster_tls_no_mesh +def test_update_coredns(cluster_clients: dict[str, kubernetes.client.ApiClient]): + hosts = [ + ("172.18.255.211", "test.kind-e2e-cluster-1.interconnected"), + ( + "172.18.255.211", + "multi-cluster-replica-set-0-0.kind-e2e-cluster-1.interconnected", + ), + ( + "172.18.255.212", + "multi-cluster-replica-set-0-1.kind-e2e-cluster-1.interconnected", + ), + ( + "172.18.255.213", + "multi-cluster-replica-set-0-2.kind-e2e-cluster-1.interconnected", + ), + ("172.18.255.221", "test.kind-e2e-cluster-2.interconnected"), + ( + "172.18.255.221", + "multi-cluster-replica-set-1-0.kind-e2e-cluster-2.interconnected", + ), + ( + "172.18.255.222", + "multi-cluster-replica-set-1-1.kind-e2e-cluster-2.interconnected", + ), + ( + "172.18.255.223", + "multi-cluster-replica-set-1-2.kind-e2e-cluster-2.interconnected", + ), + ("172.18.255.231", "test.kind-e2e-cluster-3.interconnected"), + ( + "172.18.255.231", + "multi-cluster-replica-set-2-0.kind-e2e-cluster-3.interconnected", + ), + ( + "172.18.255.232", + "multi-cluster-replica-set-2-1.kind-e2e-cluster-3.interconnected", + ), + ( + "172.18.255.233", + "multi-cluster-replica-set-2-2.kind-e2e-cluster-3.interconnected", + ), + ] + + for cluster_name, cluster_api in cluster_clients.items(): + update_coredns_hosts(hosts, cluster_name, api_client=cluster_api) + + +@mark.e2e_multi_cluster_tls_no_mesh +def test_deploy_operator(multi_cluster_operator: Operator): + multi_cluster_operator.assert_is_running() + + +@mark.e2e_multi_cluster_tls_no_mesh +def test_create_mongodb_multi( + mongodb_multi: MongoDBMulti, + namespace: str, + server_certs: str, + multi_cluster_issuer_ca_configmap: str, + member_cluster_clients: List[MultiClusterClient], + member_cluster_names: List[str], +): + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=2400) + + +@mark.e2e_multi_cluster_tls_no_mesh +def test_service_overrides( + namespace: str, + mongodb_multi: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], +): + for cluster_idx, member_cluster_client in enumerate(member_cluster_clients): + for pod_idx in range(0, 2): + external_service_name = f"{mongodb_multi.name}-{cluster_idx}-{pod_idx}-svc-external" + external_service = get_service( + namespace, + external_service_name, + api_client=member_cluster_client.api_client, + ) + + assert external_service is not None + assert external_service.spec.type == "LoadBalancer" + assert external_service.spec.publish_not_ready_addresses + ports = external_service.spec.ports + assert len(ports) == 3 + assert ports[0].name == "mongodb" + assert ports[0].target_port == 27017 + assert ports[0].port == 27017 + assert ports[1].name == "backup" + assert ports[1].target_port == 27018 + assert ports[1].port == 27018 + assert ports[2].name == f"testing{cluster_idx}" + assert ports[2].target_port == 27019 + assert ports[2].port == 27019 diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_tls_with_scram.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_tls_with_scram.py new file mode 100644 index 000000000..fe5a8149a --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_tls_with_scram.py @@ -0,0 +1,225 @@ +from pytest import mark, fixture +from typing import List +from kubetester.certs import create_multi_cluster_mongodb_tls_certs +import kubernetes + +from kubetester.automation_config_tester import AutomationConfigTester +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from kubetester.kubetester import skip_if_local +from kubetester.mongotester import with_tls +from kubetester.operator import Operator +from kubetester.kubetester import fixture as yaml_fixture, KubernetesTester +from kubetester.mongodb import Phase +from kubetester.mongodb_user import MongoDBUser +from kubetester import create_secret, read_secret +from kubetester.mongotester import with_scram +from tests.multicluster.conftest import cluster_spec_list + +CERT_SECRET_PREFIX = "clustercert" +MDB_RESOURCE = "multi-cluster-replica-set" +BUNDLE_SECRET_NAME = f"{CERT_SECRET_PREFIX}-{MDB_RESOURCE}-cert" +USER_NAME = "my-user-1" +USER_RESOURCE = "multi-replica-set-scram-user" +USER_DATABASE = "admin" +PASSWORD_SECRET_NAME = "mms-user-1-password" +USER_PASSWORD = "my-password" + + +@fixture(scope="module") +def mongodb_multi_unmarshalled( + namespace: str, + member_cluster_names: list[str], +) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml(yaml_fixture("mongodb-multi.yaml"), MDB_RESOURCE, namespace) + resource["spec"]["clusterSpecList"] = cluster_spec_list(member_cluster_names, [2, 1, 2]) + + return resource + + +@fixture(scope="module") +def server_certs( + multi_cluster_issuer: str, + mongodb_multi_unmarshalled: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], + central_cluster_client: kubernetes.client.ApiClient, +): + + return create_multi_cluster_mongodb_tls_certs( + multi_cluster_issuer, + BUNDLE_SECRET_NAME, + member_cluster_clients, + central_cluster_client, + mongodb_multi_unmarshalled, + ) + + +@fixture(scope="module") +def mongodb_multi( + central_cluster_client: kubernetes.client.ApiClient, + server_certs: str, + mongodb_multi_unmarshalled: MongoDBMulti, + multi_cluster_issuer_ca_configmap: str, +) -> MongoDBMulti: + + resource = mongodb_multi_unmarshalled + resource["spec"]["security"] = { + "certsSecretPrefix": CERT_SECRET_PREFIX, + "tls": { + "ca": multi_cluster_issuer_ca_configmap, + }, + } + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + return resource.create() + + +@fixture(scope="module") +def mongodb_user(central_cluster_client: kubernetes.client.ApiClient, namespace: str) -> MongoDBUser: + resource = MongoDBUser.from_yaml(yaml_fixture("mongodb-user.yaml"), USER_RESOURCE, namespace) + + resource["spec"]["username"] = USER_NAME + resource["spec"]["passwordSecretKeyRef"] = { + "name": PASSWORD_SECRET_NAME, + "key": "password", + } + resource["spec"]["mongodbResourceRef"]["name"] = MDB_RESOURCE + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + return resource.create() + + +@mark.e2e_multi_cluster_tls_with_scram +def test_deploy_operator(multi_cluster_operator: Operator): + multi_cluster_operator.assert_is_running() + + +@mark.e2e_multi_cluster_tls_with_scram +def test_deploy_mongodb_multi_with_tls( + mongodb_multi: MongoDBMulti, + namespace: str, + member_cluster_clients: List[MultiClusterClient], +): + + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=1200) + + +@mark.e2e_multi_cluster_tls_with_scram +def test_update_mongodb_multi_tls_with_scram( + mongodb_multi: MongoDBMulti, + namespace: str, +): + mongodb_multi.load() + mongodb_multi["spec"]["security"] = {"authentication": {"enabled": True, "modes": ["SCRAM"]}} + mongodb_multi.update() + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=700) + + +@mark.e2e_multi_cluster_tls_with_scram +def test_create_mongodb_user( + central_cluster_client: kubernetes.client.ApiClient, + mongodb_user: MongoDBUser, + namespace: str, +): + # create user secret first + create_secret( + namespace=namespace, + name=PASSWORD_SECRET_NAME, + data={"password": USER_PASSWORD}, + api_client=central_cluster_client, + ) + mongodb_user.assert_reaches_phase(Phase.Updated, timeout=100) + + +@skip_if_local +@mark.e2e_multi_cluster_tls_with_scram +def test_tls_connectivity(mongodb_multi: MongoDBMulti, ca_path: str): + tester = mongodb_multi.tester() + tester.assert_connectivity(opts=[with_tls(use_tls=True, ca_path=ca_path)]) + + +@skip_if_local +@mark.e2e_multi_cluster_tls_with_scram +def test_replica_set_connectivity_with_scram_and_tls(mongodb_multi: MongoDBMulti, ca_path: str): + tester = mongodb_multi.tester() + tester.assert_connectivity( + db="admin", + opts=[ + with_scram(USER_NAME, USER_PASSWORD), + with_tls(use_tls=True, ca_path=ca_path), + ], + ) + + +@skip_if_local +@mark.e2e_multi_cluster_tls_with_scram +def test_replica_set_connectivity_from_connection_string_standard( + namespace: str, + mongodb_multi: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], + ca_path: str, +): + secret_data = read_secret( + namespace, + f"{mongodb_multi.name}-{USER_RESOURCE}-{USER_DATABASE}", + api_client=member_cluster_clients[0].api_client, + ) + tester = mongodb_multi.tester() + tester.cnx_string = secret_data["connectionString.standard"] + tester.assert_connectivity( + db="admin", + opts=[ + with_scram(USER_NAME, USER_PASSWORD), + with_tls(use_tls=True, ca_path=ca_path), + ], + ) + + +@skip_if_local +@mark.e2e_multi_cluster_tls_with_scram +def test_replica_set_connectivity_from_connection_string_standard_srv( + namespace: str, + mongodb_multi: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], + ca_path: str, +): + secret_data = read_secret( + namespace, + f"{mongodb_multi.name}-{USER_RESOURCE}-{USER_DATABASE}", + api_client=member_cluster_clients[-1].api_client, + ) + tester = mongodb_multi.tester() + tester.cnx_string = secret_data["connectionString.standardSrv"] + tester.assert_connectivity( + db="admin", + opts=[ + with_scram(USER_NAME, USER_PASSWORD), + with_tls(use_tls=True, ca_path=ca_path), + ], + ) + + +@mark.e2e_multi_cluster_tls_with_scram +def test_mongodb_multi_tls_enable_x509( + mongodb_multi: MongoDBMulti, + namespace: str, +): + mongodb_multi.load() + + mongodb_multi["spec"]["security"]["authentication"]["modes"].append("X509") + mongodb_multi["spec"]["security"]["authentication"]["agents"] = {"mode": "SCRAM"} + mongodb_multi.update() + + mongodb_multi.assert_abandons_phase(Phase.Running, timeout=120) + # sometimes the agents need more time to register than the time we wait -> + # "Failed to enable Authentication for MongoDB Multi Replicaset" + # after this the agents eventually succeed. + mongodb_multi.assert_reaches_phase(Phase.Running, ignore_errors=True, timeout=1000) + + +@mark.e2e_multi_cluster_tls_with_scram +def test_mongodb_multi_tls_automation_config_was_updated( + mongodb_multi: MongoDBMulti, + namespace: str, +): + tester = AutomationConfigTester(KubernetesTester.get_automation_config()) + tester.assert_authentication_mechanism_enabled("MONGODB-X509", active_auth_mechanism=False) + tester.assert_authentication_mechanism_enabled("SCRAM-SHA-256") + tester.assert_authentication_enabled(expected_num_deployment_auth_mechanisms=2) diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_tls_with_x509.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_tls_with_x509.py new file mode 100644 index 000000000..f5f047fec --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_tls_with_x509.py @@ -0,0 +1,227 @@ +import tempfile +from typing import List + +import kubernetes +from pytest import mark, fixture + +from kubetester import create_or_update +from kubetester.automation_config_tester import AutomationConfigTester +from kubetester.certs import ( + create_multi_cluster_mongodb_x509_tls_certs, + create_multi_cluster_x509_user_cert, + create_multi_cluster_x509_agent_certs, + Certificate, +) +from kubetester.kubetester import fixture as yaml_fixture, KubernetesTester +from kubetester.kubetester import skip_if_local +from kubetester.mongodb import Phase +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from kubetester.mongodb_user import MongoDBUser +from kubetester.operator import Operator +from tests.multicluster.conftest import cluster_spec_list + +CERT_SECRET_PREFIX = "clustercert" +MDB_RESOURCE = "multi-cluster-replica-set" +BUNDLE_SECRET_NAME = f"{CERT_SECRET_PREFIX}-{MDB_RESOURCE}-cert" +AGENT_BUNDLE_SECRET_NAME = f"{CERT_SECRET_PREFIX}-{MDB_RESOURCE}-agent-certs" +CLUSTER_BUNDLE_SECRET_NAME = f"{CERT_SECRET_PREFIX}-{MDB_RESOURCE}-clusterfile" + + +@fixture(scope="module") +def mongodb_multi_unmarshalled(namespace: str, member_cluster_names) -> MongoDBMulti: + resource = MongoDBMulti.from_yaml(yaml_fixture("mongodb-multi.yaml"), MDB_RESOURCE, namespace) + resource["spec"]["clusterSpecList"] = cluster_spec_list(member_cluster_names, [2, 1, 2]) + + return resource + + +@fixture(scope="module") +def server_certs( + multi_cluster_issuer: str, + mongodb_multi_unmarshalled: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], + central_cluster_client: kubernetes.client.ApiClient, +): + + return create_multi_cluster_mongodb_x509_tls_certs( + multi_cluster_issuer, + BUNDLE_SECRET_NAME, + member_cluster_clients, + central_cluster_client, + mongodb_multi_unmarshalled, + ) + + +@fixture(scope="module") +def cluster_certs( + multi_cluster_issuer: str, + mongodb_multi_unmarshalled: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], + central_cluster_client: kubernetes.client.ApiClient, +): + + return create_multi_cluster_mongodb_x509_tls_certs( + multi_cluster_issuer, + CLUSTER_BUNDLE_SECRET_NAME, + member_cluster_clients, + central_cluster_client, + mongodb_multi_unmarshalled, + ) + + +@fixture(scope="module") +def agent_certs( + multi_cluster_issuer: str, + mongodb_multi_unmarshalled: MongoDBMulti, + member_cluster_clients: List[MultiClusterClient], + central_cluster_client: kubernetes.client.ApiClient, +): + return create_multi_cluster_x509_agent_certs( + multi_cluster_issuer, + AGENT_BUNDLE_SECRET_NAME, + central_cluster_client, + mongodb_multi_unmarshalled, + ) + + +@fixture(scope="module") +def mongodb_multi( + central_cluster_client: kubernetes.client.ApiClient, + server_certs: str, + mongodb_multi_unmarshalled: MongoDBMulti, + multi_cluster_issuer_ca_configmap: str, + member_cluster_names, +) -> MongoDBMulti: + + resource = mongodb_multi_unmarshalled + resource["spec"]["security"] = { + "certsSecretPrefix": CERT_SECRET_PREFIX, + "tls": { + "ca": multi_cluster_issuer_ca_configmap, + }, + } + + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + create_or_update(resource) + + return resource + + +@fixture(scope="module") +def mongodb_x509_user(central_cluster_client: kubernetes.client.ApiClient, namespace: str) -> MongoDBUser: + resource = MongoDBUser.from_yaml(yaml_fixture("mongodb-x509-user.yaml"), "multi-replica-set-x509-user", namespace) + resource["spec"]["mongodbResourceRef"]["name"] = MDB_RESOURCE + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + + create_or_update(resource) + + return resource + + +@mark.e2e_multi_cluster_tls_with_x509 +def test_deploy_operator(multi_cluster_operator: Operator): + multi_cluster_operator.assert_is_running() + + +@mark.e2e_multi_cluster_tls_with_x509 +def test_deploy_mongodb_multi_with_tls(mongodb_multi: MongoDBMulti, namespace: str): + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=1200) + + +@mark.e2e_multi_cluster_tls_with_x509 +def test_rotate_cert_and_assert_mdb_running( + namespace: str, + mongodb_multi: MongoDBMulti, + central_cluster_client: kubernetes.client.ApiClient, +): + assert_certificate_rotation(central_cluster_client, mongodb_multi, namespace, BUNDLE_SECRET_NAME) + + +@mark.e2e_multi_cluster_tls_with_x509 +def test_mongodb_multi_tls_enable_x509( + mongodb_multi: MongoDBMulti, + namespace: str, + agent_certs: str, +): + mongodb_multi.load() + + mongodb_multi["spec"]["security"] = { + "authentication": { + "enabled": True, + "modes": ["X509"], + "agents": {"mode": "X509"}, + } + } + mongodb_multi.update() + + mongodb_multi.assert_abandons_phase(Phase.Running, timeout=50) + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=1000) + + +@mark.e2e_multi_cluster_tls_with_x509 +def test_create_mongodb_x509_user( + central_cluster_client: kubernetes.client.ApiClient, + mongodb_x509_user: MongoDBUser, + namespace: str, +): + mongodb_x509_user.assert_reaches_phase(Phase.Updated, timeout=100) + + +@skip_if_local +@mark.e2e_multi_cluster_tls_with_x509 +def test_x509_user_connectivity( + mongodb_multi: MongoDBMulti, + central_cluster_client: kubernetes.client.ApiClient, + multi_cluster_issuer: str, + namespace: str, + ca_path: str, +): + with tempfile.NamedTemporaryFile(delete=False, mode="w") as cert_file: + create_multi_cluster_x509_user_cert( + multi_cluster_issuer, namespace, central_cluster_client, path=cert_file.name + ) + tester = mongodb_multi.tester() + tester.assert_x509_authentication(cert_file_name=cert_file.name, ssl_ca_certs=ca_path) + + +@mark.e2e_multi_cluster_tls_with_x509 +def test_mongodb_multi_tls_enable_internal_cluster_x509( + mongodb_multi: MongoDBMulti, + namespace: str, + cluster_certs: str, + agent_certs: str, +): + mongodb_multi.load() + + mongodb_multi["spec"]["security"]["authentication"]["internalCluster"] = "X509" + mongodb_multi.update() + + mongodb_multi.assert_abandons_phase(Phase.Running, timeout=50) + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=1000) + + +@mark.e2e_multi_cluster_tls_with_x509 +def test_ops_manager_state_was_updated_correctly(mongodb_multi: MongoDBMulti): + ac_tester = AutomationConfigTester(KubernetesTester.get_automation_config()) + ac_tester.assert_authentication_enabled() + ac_tester.assert_authentication_mechanism_enabled("MONGODB-X509") + ac_tester.assert_internal_cluster_authentication_enabled() + + +@mark.e2e_multi_cluster_tls_with_x509 +def test_rotate_certfile_and_assert_mdb_running( + mongodb_multi: MongoDBMulti, + namespace: str, + central_cluster_client: kubernetes.client.ApiClient, +): + assert_certificate_rotation(central_cluster_client, mongodb_multi, namespace, CLUSTER_BUNDLE_SECRET_NAME) + + +def assert_certificate_rotation(central_cluster_client, mongodb_multi, namespace, certificate_name): + cert = Certificate(name=certificate_name, namespace=namespace) + cert.api = kubernetes.client.CustomObjectsApi(api_client=central_cluster_client) + cert.load() + cert["spec"]["dnsNames"].append("foo") # Append DNS to cert to rotate the certificate + cert.update() + mongodb_multi.assert_abandons_phase(Phase.Running, timeout=120) + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=1200) diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_upgrade_downgrade.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_upgrade_downgrade.py new file mode 100644 index 000000000..dfaf9514b --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_upgrade_downgrade.py @@ -0,0 +1,83 @@ +import kubernetes +import pytest + +from kubetester import create_or_update +from kubetester.automation_config_tester import AutomationConfigTester +from kubetester.mongodb import Phase +from kubetester.mongodb_multi import MongoDBMulti +from kubetester.kubetester import ( + fixture as yaml_fixture, + skip_if_local, +) +from kubetester.operator import Operator +from tests.multicluster.conftest import cluster_spec_list + +MDBM_RESOURCE = "multi-replica-set-upgrade" + + +@pytest.fixture(scope="module") +def mongodb_multi( + central_cluster_client: kubernetes.client.ApiClient, + namespace: str, + member_cluster_names: list[str], +) -> MongoDBMulti: + + resource = MongoDBMulti.from_yaml(yaml_fixture("mongodb-multi.yaml"), MDBM_RESOURCE, namespace) + resource["spec"]["clusterSpecList"] = cluster_spec_list(member_cluster_names, [2, 1, 2]) + resource["spec"]["version"] = "4.4.11-ent" + resource.api = kubernetes.client.CustomObjectsApi(central_cluster_client) + + return create_or_update(resource) + + +@pytest.mark.e2e_multi_cluster_upgrade_downgrade +def test_deploy_operator(multi_cluster_operator: Operator): + multi_cluster_operator.assert_is_running() + + +@skip_if_local +@pytest.mark.e2e_multi_cluster_upgrade_downgrade +def test_create_mongodb_multi_running(mongodb_multi: MongoDBMulti): + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=700) + mongodb_multi.tester().assert_version("4.4.11-ent") + + +@skip_if_local +@pytest.mark.e2e_multi_cluster_upgrade_downgrade +def test_mongodb_multi_upgrade(mongodb_multi: MongoDBMulti): + mongodb_multi.load() + mongodb_multi["spec"]["version"] = "5.0.5-ent" + mongodb_multi["spec"]["featureCompatibilityVersion"] = "4.4" + mongodb_multi.update() + + mongodb_multi.assert_abandons_phase(Phase.Running) + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=700) + + mongodb_multi.tester().assert_version("5.0.5-ent") + + +@skip_if_local +@pytest.mark.e2e_multi_cluster_upgrade_downgrade +def test_upgraded_replica_set_is_reachable(mongodb_multi: MongoDBMulti): + tester = mongodb_multi.tester() + tester.assert_connectivity() + + +@skip_if_local +@pytest.mark.e2e_multi_cluster_upgrade_downgrade +def test_mongodb_multi_downgrade(mongodb_multi: MongoDBMulti): + mongodb_multi.load() + mongodb_multi["spec"]["version"] = "4.4.11-ent" + mongodb_multi["spec"]["featureCompatibilityVersion"] = None + mongodb_multi.update() + + mongodb_multi.assert_abandons_phase(Phase.Running) + mongodb_multi.assert_reaches_phase(Phase.Running, timeout=700) + mongodb_multi.tester().assert_version("4.4.11-ent") + + +@skip_if_local +@pytest.mark.e2e_multi_cluster_upgrade_downgrade +def test_downgraded_replica_set_is_reachable(mongodb_multi: MongoDBMulti): + tester = mongodb_multi.tester() + tester.assert_connectivity() diff --git a/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_validation.py b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_validation.py new file mode 100644 index 000000000..d4d26cf4a --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/multicluster/multi_cluster_validation.py @@ -0,0 +1,37 @@ +from typing import Dict, List + +import kubernetes +import pytest +import yaml +from kubetester.mongodb import Phase +from kubetester.mongodb_multi import MongoDBMulti, MultiClusterClient +from kubetester.operator import Operator +from kubetester.kubetester import fixture as yaml_fixture, KubernetesTester + + +@pytest.mark.e2e_multi_cluster_validation +class TestWebhookValidation(KubernetesTester): + def test_deploy_operator(self, multi_cluster_operator: Operator): + multi_cluster_operator.assert_is_running() + + def test_duplicate_cluster_names(self, central_cluster_client: kubernetes.client.ApiClient): + resource = yaml.safe_load(open(yaml_fixture("mongodb-multi-cluster.yaml"))) + resource["spec"]["clusterSpecList"].append({"clusterName": "kind-e2e-cluster-1", "members": 1}) + + self.create_custom_resource_from_object( + self.get_namespace(), + resource, + exception_reason="Multiple clusters with the same name (kind-e2e-cluster-1) are not allowed", + api_client=central_cluster_client, + ) + + def test_only_one_schema(self, central_cluster_client: kubernetes.client.ApiClient): + resource = yaml.safe_load(open(yaml_fixture("mongodb-multi-cluster.yaml"))) + resource["spec"]["cloudManager"] = {"configMapRef": {"name": " my-project"}} + + self.create_custom_resource_from_object( + self.get_namespace(), + resource, + exception_reason="must validate one and only one schema", + api_client=central_cluster_client, + ) diff --git a/docker/mongodb-enterprise-tests/tests/olm/__init__.py b/docker/mongodb-enterprise-tests/tests/olm/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/docker/mongodb-enterprise-tests/tests/olm/fixtures/olm_ops_manager_backup.yaml b/docker/mongodb-enterprise-tests/tests/olm/fixtures/olm_ops_manager_backup.yaml new file mode 100644 index 000000000..716c31347 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/olm/fixtures/olm_ops_manager_backup.yaml @@ -0,0 +1,47 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: om-backup +spec: + replicas: 2 + adminCredentials: ops-manager-admin-secret + backup: + enabled: true + headDB: + storage: 500M + opLogStores: + - name: oplog1 + mongodbResourceRef: + name: my-mongodb-oplog + blockStores: + - name: blockStore1 + mongodbResourceRef: + name: my-mongodb-blockstore + s3Stores: + - name: s3Store1 + mongodbResourceRef: + name: my-mongodb-s3 + s3SecretRef: + name: my-s3-secret + pathStyleAccessEnabled: true + s3BucketEndpoint: s3.us-east-1.amazonaws.com + s3BucketName: test-bucket + + applicationDatabase: + members: 3 + + # Dev: adding this just to avoid wizard when opening OM UI + # (note, that to debug issues in OM you need to add 'spec.externalConnectivity.type=NodePort' + # and specify some port: 'port: 32400'. Don't forget to open it in AWS) + configuration: + automation.versions.source: mongodb + mms.adminEmailAddr: cloud-manager-support@mongodb.com + mms.fromEmailAddr: cloud-manager-support@mongodb.com + mms.ignoreInitialUiSetup: "true" + mms.mail.hostname: email-smtp.us-east-1.amazonaws.com + mms.mail.port: "465" + mms.mail.ssl: "true" + mms.mail.transport: smtp + mms.minimumTLSVersion: TLSv1.2 + mms.replyToEmailAddr: cloud-manager-support@mongodb.com + diff --git a/docker/mongodb-enterprise-tests/tests/olm/fixtures/olm_replica_set_for_om.yaml b/docker/mongodb-enterprise-tests/tests/olm/fixtures/olm_replica_set_for_om.yaml new file mode 100644 index 000000000..3f429bc36 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/olm/fixtures/olm_replica_set_for_om.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: the-replica-set +spec: + members: 3 + type: ReplicaSet + opsManager: + configMapRef: + name: om-rs-configmap + credentials: my-credentials + persistent: true + logLevel: DEBUG + diff --git a/docker/mongodb-enterprise-tests/tests/olm/fixtures/olm_scram_sha_user_backing_db.yaml b/docker/mongodb-enterprise-tests/tests/olm/fixtures/olm_scram_sha_user_backing_db.yaml new file mode 100644 index 000000000..c4103400a --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/olm/fixtures/olm_scram_sha_user_backing_db.yaml @@ -0,0 +1,20 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDBUser +metadata: + name: mms-user-1 +spec: + passwordSecretKeyRef: + name: mms-user-1-password + key: password + username: "mms-user-1@/" + db: "admin" + mongodbResourceRef: + name: "my-replica-set" + roles: + - db: "admin" + name: "clusterMonitor" + - db: "admin" + name: "readWriteAnyDatabase" + - db: "admin" + name: "dbAdminAnyDatabase" diff --git a/docker/mongodb-enterprise-tests/tests/olm/fixtures/olm_sharded_cluster_for_om.yaml b/docker/mongodb-enterprise-tests/tests/olm/fixtures/olm_sharded_cluster_for_om.yaml new file mode 100644 index 000000000..0560a3625 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/olm/fixtures/olm_sharded_cluster_for_om.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: the-sharded-cluster +spec: + shardCount: 2 + mongodsPerShardCount: 3 + mongosCount: 2 + configServerCount: 2 + type: ShardedCluster + opsManager: + configMapRef: + name: om-sc-configmap + credentials: my-credentials + persistent: false + logLevel: DEBUG + diff --git a/docker/mongodb-enterprise-tests/tests/olm/olm_operator_upgrade.py b/docker/mongodb-enterprise-tests/tests/olm/olm_operator_upgrade.py new file mode 100644 index 000000000..4f7ff8a99 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/olm/olm_operator_upgrade.py @@ -0,0 +1,53 @@ +from kubetester import create_or_update +from tests.olm.olm_test_commons import ( + get_current_operator_version, + increment_patch_version, + get_operator_group_resource, + get_catalog_source_resource, + get_catalog_image, + get_subscription_custom_object, + wait_for_operator_ready, +) +import pytest + + +# See docs how to run this locally: https://wiki.corp.mongodb.com/display/MMS/E2E+Tests+Notes#E2ETestsNotes-OLMtests + +# This tests only OLM upgrade of the operator without deploying any resources. + + +@pytest.mark.e2e_olm_operator_upgrade +def test_upgrade_operator_only(namespace: str, version_id: str): + current_operator_version = get_current_operator_version() + incremented_operator_version = increment_patch_version(current_operator_version) + + create_or_update(get_operator_group_resource(namespace, namespace)) + catalog_source_resource = get_catalog_source_resource( + namespace, get_catalog_image(f"{incremented_operator_version}-{version_id}") + ) + create_or_update(catalog_source_resource) + + subscription = get_subscription_custom_object( + "mongodb-enterprise-operator", + namespace, + { + "channel": "stable", # stable channel contains latest released operator in RedHat's certified repository + "name": "mongodb-enterprise", + "source": catalog_source_resource.name, + "sourceNamespace": namespace, + "installPlanApproval": "Automatic", + # In certified OpenShift bundles we have this enabled, so the operator is not defining security context (it's managed globally by OpenShift). + # In Kind this will result in empty security contexts and problems deployments with filesystem permissions. + "config": {"env": [{"name": "MANAGED_SECURITY_CONTEXT", "value": "false"}]}, + }, + ) + + create_or_update(subscription) + + wait_for_operator_ready(namespace, f"mongodb-enterprise.v{current_operator_version}") + + subscription.load() + subscription["spec"]["channel"] = "fast" # fast channel contains operator build from the current branch + subscription.update() + + wait_for_operator_ready(namespace, f"mongodb-enterprise.v{incremented_operator_version}") diff --git a/docker/mongodb-enterprise-tests/tests/olm/olm_operator_upgrade_with_resources.py b/docker/mongodb-enterprise-tests/tests/olm/olm_operator_upgrade_with_resources.py new file mode 100644 index 000000000..49cf52773 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/olm/olm_operator_upgrade_with_resources.py @@ -0,0 +1,357 @@ +import pytest +from kubeobject import CustomObject +from pytest import fixture + +from kubetester import create_or_update, MongoDB, try_load, get_default_storage_class, create_or_update_secret +from kubetester.awss3client import AwsS3Client +from kubetester.certs import create_sharded_cluster_certs +from kubetester.kubetester import ( + fixture as yaml_fixture, + KubernetesTester, + run_periodically, +) +from kubetester.mongodb import Phase +from kubetester.mongodb_user import MongoDBUser +from kubetester.opsmanager import MongoDBOpsManager +from tests.olm.olm_test_commons import ( + get_current_operator_version, + increment_patch_version, + get_operator_group_resource, + get_catalog_source_resource, + get_catalog_image, + get_subscription_custom_object, + wait_for_operator_ready, +) +from tests.opsmanager.conftest import ensure_ent_version +from tests.opsmanager.om_ops_manager_backup import create_aws_secret, create_s3_bucket + + +# See docs how to run this locally: https://wiki.corp.mongodb.com/display/MMS/E2E+Tests+Notes#E2ETestsNotes-OLMtests + +# This test performs operator upgrade of the operator while having OM and MongoDB resources deployed. +# It performs the following actions: +# - deploy latest released operator using OLM +# - deploy OM +# - deploy backup-required MongoDB: oplog, s3, blockstore +# - deploy TLS-enabled sharded MongoDB +# - check everything is running +# - upgrade the operator to the version built from the current branch +# - wait for resources to be rolling-updated due to updated stateful sets by the new operator +# - check everything is running and connectable + + +@fixture +def catalog_source(namespace: str, version_id: str): + current_operator_version = get_current_operator_version() + incremented_operator_version = increment_patch_version(current_operator_version) + + create_or_update(get_operator_group_resource(namespace, namespace)) + catalog_source_resource = get_catalog_source_resource( + namespace, get_catalog_image(f"{incremented_operator_version}-{version_id}") + ) + create_or_update(catalog_source_resource) + + return catalog_source_resource + + +@fixture +def subscription(namespace: str, catalog_source: CustomObject): + return get_subscription_custom_object( + "mongodb-enterprise-operator", + namespace, + { + "channel": "stable", # stable channel contains latest released operator in RedHat's certified repository + "name": "mongodb-enterprise", + "source": catalog_source.name, + "sourceNamespace": namespace, + "installPlanApproval": "Automatic", + # In certified OpenShift bundles we have this enabled, so the operator is not defining security context (it's managed globally by OpenShift). + # In Kind this will result in empty security contexts and problems deployments with filesystem permissions. + "config": {"env": [{"name": "MANAGED_SECURITY_CONTEXT", "value": "false"}]}, + }, + ) + + +@fixture +def current_operator_version(): + return get_current_operator_version() + + +@pytest.mark.e2e_olm_operator_upgrade_with_resources +def test_install_stable_operator_version( + namespace: str, + version_id: str, + current_operator_version: str, + catalog_source: CustomObject, + subscription: CustomObject, +): + create_or_update(subscription) + wait_for_operator_ready(namespace, f"mongodb-enterprise.v{current_operator_version}") + + +# install resources on the latest released version of the operator + + +@fixture(scope="module") +def ops_manager(namespace: str) -> MongoDBOpsManager: + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("olm_ops_manager_backup.yaml"), namespace=namespace + ) + + try_load(resource) + return resource + + +@fixture(scope="module") +def s3_bucket(aws_s3_client: AwsS3Client, namespace: str) -> str: + create_aws_secret(aws_s3_client, "my-s3-secret", namespace) + yield from create_s3_bucket(aws_s3_client, "test-bucket-s3") + + +@pytest.mark.e2e_olm_operator_upgrade_with_resources +def test_create_om( + ops_manager: MongoDBOpsManager, + s3_bucket: str, + custom_version: str, + custom_appdb_version: str, +): + KubernetesTester.make_default_gp2_storage_class() + + try_load(ops_manager) + ops_manager["spec"]["backup"]["s3Stores"][0]["s3BucketName"] = s3_bucket + ops_manager["spec"]["backup"]["headDB"]["storageClass"] = get_default_storage_class() + ops_manager["spec"]["backup"]["members"] = 2 + + ops_manager.set_version(custom_version) + # we don't use latest AppDB here this time, due to the lack of support for the new official AppDB image in the latest released bundle + # TODO: change it to custom_appdb_version when 1.20 is released + ops_manager.set_appdb_version("5.0.7-ent") + ops_manager.allow_mdb_rc_versions() + + create_or_update(ops_manager) + + +def wait_for_om_healthy_response(ops_manager: MongoDBOpsManager): + def wait_for_om_healthy_response_fn(): + status_code, status_response = ops_manager.get_om_tester().check_healthiness() + if status_code == 200: + return True, f"Got healthy status_code=200: {status_response}" + else: + return False, f"Got unhealthy status_code={status_code}: {status_response}" + + run_periodically(wait_for_om_healthy_response_fn, timeout=300, msg=f"OM returning healthy response") + + +@pytest.mark.e2e_olm_operator_upgrade_with_resources +def test_om_connectivity(ops_manager: MongoDBOpsManager): + ops_manager.appdb_status().assert_reaches_phase(Phase.Running) + ops_manager.om_status().assert_reaches_phase(Phase.Running) + ops_manager.backup_status().assert_reaches_phase(Phase.Pending) + # backup not yet configured + + wait_for_om_healthy_response(ops_manager) + + +# sharded mongodb fixtures + + +@fixture(scope="module") +def mdb_sharded_certs(issuer: str, namespace: str): + create_sharded_cluster_certs( + namespace, + "mdb-sharded", + shards=1, + mongos_per_shard=2, + config_servers=1, + mongos=1, + secret_prefix="prefix-", + ) + + +@fixture +def mdb_sharded( + ops_manager: MongoDBOpsManager, namespace, custom_mdb_version: str, issuer_ca_configmap: str, mdb_sharded_certs +): + resource = MongoDB.from_yaml( + yaml_fixture("olm_sharded_cluster_for_om.yaml"), namespace=namespace, name="mdb-sharded" + ).configure(ops_manager, "mdb-sharded") + resource["spec"]["version"] = ensure_ent_version(custom_mdb_version) + resource["spec"]["security"] = { + "tls": { + "ca": issuer_ca_configmap, + }, + } + resource.configure_backup(mode="disabled") + create_or_update(resource) + return resource + + +# OpsManager backup-backing databases + + +@fixture(scope="module") +def oplog_replica_set(ops_manager, namespace, custom_mdb_version: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("olm_replica_set_for_om.yaml"), namespace=namespace, name="my-mongodb-oplog" + ).configure(ops_manager, "oplog") + resource["spec"]["version"] = custom_mdb_version + return create_or_update(resource) + + +@fixture(scope="module") +def s3_replica_set(ops_manager, namespace, custom_mdb_version: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("olm_replica_set_for_om.yaml"), namespace=namespace, name="my-mongodb-s3" + ).configure(ops_manager, "s3metadata") + resource["spec"]["version"] = custom_mdb_version + return create_or_update(resource) + + +@fixture(scope="module") +def blockstore_replica_set(ops_manager, namespace, custom_mdb_version: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("olm_replica_set_for_om.yaml"), namespace=namespace, name="my-mongodb-blockstore" + ).configure(ops_manager, "blockstore") + resource["spec"]["version"] = custom_mdb_version + return create_or_update(resource) + + +@fixture(scope="module") +def blockstore_user(namespace, blockstore_replica_set: MongoDB) -> MongoDBUser: + return create_secret_and_user( + namespace, "blockstore-user", blockstore_replica_set.name, "blockstore-user-password-secret", "Passw0rd." + ) + + +@fixture(scope="module") +def oplog_user(namespace, oplog_replica_set: MongoDB) -> MongoDBUser: + return create_secret_and_user( + namespace, "oplog-user", oplog_replica_set.name, "oplog-user-password-secret", "Passw0rd." + ) + + +def create_secret_and_user( + namespace: str, name: str, replica_set_name: str, secret_name: str, password: str +) -> MongoDBUser: + resource = MongoDBUser.from_yaml(yaml_fixture("olm_scram_sha_user_backing_db.yaml"), namespace=namespace, name=name) + resource["spec"]["mongodbResourceRef"]["name"] = replica_set_name + resource["spec"]["passwordSecretKeyRef"]["name"] = secret_name + create_or_update_secret(namespace, secret_name, {"password": password}) + return create_or_update(resource) + + +@pytest.mark.e2e_olm_operator_upgrade_with_resources +def test_resources_created( + oplog_replica_set: MongoDB, + s3_replica_set: MongoDB, + blockstore_replica_set: MongoDB, + mdb_sharded: MongoDB, + blockstore_user: MongoDBUser, + oplog_user: MongoDBUser, +): + """Creates mongodb databases all at once""" + oplog_replica_set.assert_reaches_phase(Phase.Running) + s3_replica_set.assert_reaches_phase(Phase.Running) + blockstore_replica_set.assert_reaches_phase(Phase.Running) + mdb_sharded.assert_reaches_phase(Phase.Running) + + +@pytest.mark.e2e_olm_operator_upgrade_with_resources +def test_set_backup_users(ops_manager: MongoDBOpsManager, oplog_user: MongoDBUser, blockstore_user: MongoDBUser): + ops_manager.load() + ops_manager["spec"]["backup"]["opLogStores"][0]["mongodbUserRef"] = {"name": oplog_user.name} + ops_manager["spec"]["backup"]["blockStores"][0]["mongodbUserRef"] = {"name": blockstore_user.name} + ops_manager.update() + + ops_manager.backup_status().assert_reaches_phase(Phase.Running, timeout=600, ignore_errors=True) + + assert ops_manager.backup_status().get_message() is None + + +@pytest.mark.e2e_olm_operator_upgrade_with_resources +def test_om_connectivity_with_backup( + ops_manager: MongoDBOpsManager, + oplog_replica_set: MongoDB, + s3_replica_set: MongoDB, +): + wait_for_om_healthy_response(ops_manager) + + ops_manager.appdb_status().assert_reaches_phase(Phase.Running) + ops_manager.backup_status().assert_reaches_phase(Phase.Running) + ops_manager.om_status().assert_reaches_phase(Phase.Running) + + +@pytest.mark.e2e_olm_operator_upgrade_with_resources +def test_resources_in_running_state_before_upgrade( + ops_manager: MongoDBOpsManager, + oplog_replica_set: MongoDB, + blockstore_replica_set: MongoDB, + s3_replica_set: MongoDB, + mdb_sharded: MongoDB, +): + ops_manager.appdb_status().assert_reaches_phase(Phase.Running) + ops_manager.backup_status().assert_reaches_phase(Phase.Running) + ops_manager.om_status().assert_reaches_phase(Phase.Running) + oplog_replica_set.assert_reaches_phase(Phase.Running) + blockstore_replica_set.assert_reaches_phase(Phase.Running) + s3_replica_set.assert_reaches_phase(Phase.Running) + mdb_sharded.assert_reaches_phase(Phase.Running) + + +# upgrade the operator + + +@pytest.mark.e2e_olm_operator_upgrade_with_resources +def test_operator_upgrade_to_fast( + namespace: str, version_id: str, catalog_source: CustomObject, subscription: CustomObject +): + current_operator_version = get_current_operator_version() + incremented_operator_version = increment_patch_version(current_operator_version) + + subscription.load() + subscription["spec"]["channel"] = "fast" # fast channel contains operator build from the current branch + subscription.update() + + wait_for_operator_ready(namespace, f"mongodb-enterprise.v{incremented_operator_version}") + + +@pytest.mark.e2e_olm_operator_upgrade_with_resources +def test_one_resources_not_in_running_state(ops_manager: MongoDBOpsManager, mdb_sharded: MongoDB): + # Wait for the first resource to become reconciling after operator upgrade. + # Only then wait for all to not get a false positive when all resources are ready, + # because the upgraded operator haven't started reconciling + ops_manager.om_status().assert_reaches_phase(Phase.Pending, timeout=600) + + +@pytest.mark.e2e_olm_operator_upgrade_with_resources +def test_resources_in_running_state_after_upgrade( + ops_manager: MongoDBOpsManager, + oplog_replica_set: MongoDB, + blockstore_replica_set: MongoDB, + s3_replica_set: MongoDB, + mdb_sharded: MongoDB, +): + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=900) + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=600) + ops_manager.backup_status().assert_reaches_phase(Phase.Running, timeout=600) + oplog_replica_set.assert_reaches_phase(Phase.Running, timeout=600) + blockstore_replica_set.assert_reaches_phase(Phase.Running, timeout=600) + s3_replica_set.assert_reaches_phase(Phase.Running, timeout=600) + mdb_sharded.assert_reaches_phase(Phase.Running, timeout=600) + + +@pytest.mark.e2e_olm_operator_upgrade_with_resources +def test_resources_connectivity_after_upgrade( + ca_path: str, + ops_manager: MongoDBOpsManager, + oplog_replica_set: MongoDB, + blockstore_replica_set: MongoDB, + s3_replica_set: MongoDB, + mdb_sharded: MongoDB, +): + wait_for_om_healthy_response(ops_manager) + + oplog_replica_set.assert_connectivity() + blockstore_replica_set.assert_connectivity() + s3_replica_set.assert_connectivity() + mdb_sharded.assert_connectivity(ca_path=ca_path) diff --git a/docker/mongodb-enterprise-tests/tests/olm/olm_test_commons.py b/docker/mongodb-enterprise-tests/tests/olm/olm_test_commons.py new file mode 100644 index 000000000..aab0a98a3 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/olm/olm_test_commons.py @@ -0,0 +1,111 @@ +import os +import tempfile +import time +from typing import Callable + +import yaml +from kubeobject import CustomObject +from kubernetes import client + +import kubetester +from kubetester import run_periodically + + +def custom_object_from_yaml(yaml_string: str) -> CustomObject: + tmpfile_name = tempfile.mkstemp()[1] + try: + with open(tmpfile_name, "w") as tmpfile: + tmpfile.write(yaml_string) + return CustomObject.from_yaml(tmpfile_name) + finally: + os.remove(tmpfile_name) + + +def get_operator_group_resource(namespace: str, target_namespace: str) -> CustomObject: + resource = CustomObject("mongodb-group", namespace, "OperatorGroup", "operatorgroups", "operators.coreos.com", "v1") + resource["spec"] = {"targetNamespaces": [target_namespace]} + + return resource + + +def get_catalog_source_custom_object(namespace: str, name: str): + return CustomObject(name, namespace, "CatalogSource", "catalogsources", "operators.coreos.com", "v1alpha1") + + +def get_catalog_source_resource(namespace: str, image: str) -> CustomObject: + resource = get_catalog_source_custom_object(namespace, "mongodb-operator-catalog") + resource["spec"] = { + "image": image, + "sourceType": "grpc", + "displayName": "MongoDB Enterprise Operator upgrade test", + "publisher": "MongoDB", + "updateStrategy": {"registryPoll": {"interval": "5m"}}, + } + + return resource + + +def get_subscription_custom_object(name: str, namespace: str, spec: dict[str, str]) -> CustomObject: + resource = CustomObject(name, namespace, "Subscription", "subscriptions", "operators.coreos.com", "v1alpha1") + resource["spec"] = spec + return resource + + +def get_registry(): + registry = os.getenv("REGISTRY") + if registry is None: + raise Exception("Cannot get base registry url, specify it in REGISTRY env variable.") + + return registry + + +def get_catalog_image(version: str): + return f"{get_registry()}/mongodb-enterprise-operator-certified-catalog:{version}" + + +def list_operator_pods(namespace: str, name: str) -> list[client.V1Pod]: + return client.CoreV1Api().list_namespaced_pod(namespace, label_selector=f"app.kubernetes.io/name={name}") + + +def check_operator_pod_ready_and_with_condition_version( + namespace: str, name: str, expected_condition_version +) -> (bool, str): + pod = kubetester.is_pod_ready(namespace=namespace, label_selector=f"app.kubernetes.io/name={name}") + if pod is None: + return False, f"pod {namespace}/{name} is not ready yet" + + condition_env_var = get_pod_condition_env_var(pod) + if condition_env_var != expected_condition_version: + return False, f"incorrect condition env var: expected {expected_condition_version}, got {condition_env_var}" + + return True, "" + + +def get_pod_condition_env_var(pod): + operator_container = pod.spec.containers[0] + operator_condition_env = [e for e in operator_container.env if e.name == "OPERATOR_CONDITION_NAME"] + if len(operator_condition_env) == 0: + return None + return operator_condition_env[0].value + + +def get_current_operator_version(): + with open("helm_chart/values.yaml", "r") as f: + values = yaml.safe_load(f) + return values.get("operator", {}).get("version", None) + + +def increment_patch_version(version: str): + major, minor, patch = version.split(".") + return ".".join([major, minor, str(int(patch) + 1)]) + + +def wait_for_operator_ready(namespace: str, expected_operator_version: str): + def wait_for_operator_ready_fn(): + return check_operator_pod_ready_and_with_condition_version( + namespace, "mongodb-enterprise-operator", expected_operator_version + ) + + run_periodically( + wait_for_operator_ready_fn, timeout=120, msg=f"operator ready and with {expected_operator_version} version" + ) diff --git a/docker/mongodb-enterprise-tests/tests/operator/__init__.py b/docker/mongodb-enterprise-tests/tests/operator/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/docker/mongodb-enterprise-tests/tests/operator/operator_clusterwide.py b/docker/mongodb-enterprise-tests/tests/operator/operator_clusterwide.py new file mode 100644 index 000000000..5386636f9 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/operator/operator_clusterwide.py @@ -0,0 +1,237 @@ +import time +from typing import Dict + +import pytest +from kubernetes import client +from kubetester import create_secret, read_secret +from kubetester.create_or_replace_from_yaml import create_or_replace_from_yaml +from kubetester.helm import helm_template +from kubetester.kubetester import create_testing_namespace, running_locally +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.mongodb import generic_replicaset, MongoDB, Phase +from kubetester.operator import Operator +from kubetester.opsmanager import MongoDBOpsManager +from pytest import fixture + +""" +This is the test that verifies the procedure of configuring Operator in cluster-wide scope. +See https://docs.mongodb.com/kubernetes-operator/stable/tutorial/plan-k8s-operator-install/#cluster-wide-scope + +""" + + +@fixture(scope="module") +def ops_manager_namespace(evergreen_task_id: str) -> str: + # Note, that it's safe to create the namespace with constant name as the test must be run in isolated environment + # and no collisions may happen + return create_testing_namespace(evergreen_task_id, "om-namespace") + + +@fixture(scope="module") +def mdb_namespace(evergreen_task_id: str) -> str: + return create_testing_namespace(evergreen_task_id, "mdb-namespace") + + +@fixture(scope="module") +def unmanaged_namespace(evergreen_task_id: str) -> str: + return create_testing_namespace(evergreen_task_id, "unmanaged-namespace") + + +@fixture(scope="module") +def ops_manager( + ops_manager_namespace: str, custom_version: str, custom_appdb_version: str +) -> MongoDBOpsManager: + resource = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_basic.yaml"), namespace=ops_manager_namespace + ) + resource["spec"]["backup"]["enabled"] = True + resource.set_version(custom_version) + resource.set_appdb_version(custom_appdb_version) + + return resource.create() + + +@fixture(scope="module") +def mdb(ops_manager: MongoDBOpsManager, mdb_namespace: str, namespace: str) -> MongoDB: + # we need to copy credentials secret - as the global api key secret exists in Operator namespace only + data = read_secret(namespace, ops_manager.api_key_secret(namespace)) + # we are now copying the secret from operator to mdb_namespace and the api_key_secret should therefore check for mdb_namespace, later + # mongodb.configure will reference this new secret + create_secret(mdb_namespace, ops_manager.api_key_secret(mdb_namespace), data) + + return ( + MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=mdb_namespace, + name="my-replica-set", + ) + .configure(ops_manager, "development") + .set_version("4.2.8") + .create() + ) + + +@fixture(scope="module") +def unmanaged_mdb(ops_manager: MongoDBOpsManager, unmanaged_namespace: str) -> MongoDB: + rs = generic_replicaset( + unmanaged_namespace, "5.0.0", "unmanaged-mdb", ops_manager + ).create() + + yield rs + + rs.delete() + + +@pytest.mark.e2e_operator_clusterwide +def test_install_clusterwide_operator(operator_clusterwide: Operator): + operator_clusterwide.assert_is_running() + + +@pytest.mark.e2e_operator_multi_namespaces +def test_install_multi_namespace_operator( + operator_installation_config: Dict[str, str], + ops_manager_namespace: str, + mdb_namespace: str, + namespace: str, +): + """ + Installs the operator in default namespace and watches over both OM and MDB + namespaces. + """ + + helm_args = operator_installation_config.copy() + helm_args["operator.watchNamespace"] = ops_manager_namespace + "," + mdb_namespace + + Operator(namespace=namespace, helm_args=helm_args).install().assert_is_running() + + +@pytest.mark.e2e_operator_clusterwide +def test_configure_ops_manager_namespace( + ops_manager_namespace: str, operator_installation_config: Dict[str, str] +): + """create a new namespace and configures all necessary service accounts there""" + yaml_file = helm_template( + helm_args={ + "registry.imagePullSecrets": operator_installation_config[ + "registry.imagePullSecrets" + ], + }, + templates="templates/database-roles.yaml", + helm_options=[f"--namespace {ops_manager_namespace}"], + ) + create_or_replace_from_yaml(client.api_client.ApiClient(), yaml_file) + + +@pytest.mark.e2e_operator_clusterwide +@pytest.mark.e2e_operator_multi_namespaces +def test_create_image_pull_secret_ops_manager_namespace( + namespace: str, + ops_manager_namespace: str, + operator_installation_config: Dict[str, str], +): + """We need to copy image pull secrets to om namespace""" + secret_name = operator_installation_config["registry.imagePullSecrets"] + data = read_secret(namespace, secret_name) + create_secret( + ops_manager_namespace, secret_name, data, type="kubernetes.io/dockerconfigjson" + ) + + +@pytest.mark.e2e_operator_clusterwide +def test_configure_mdb_namespace( + mdb_namespace: str, operator_installation_config: Dict[str, str] +): + yaml_file = helm_template( + helm_args={ + "registry.imagePullSecrets": operator_installation_config[ + "registry.imagePullSecrets" + ], + }, + templates="templates/database-roles.yaml", + helm_options=[f"--namespace {mdb_namespace}"], + ) + create_or_replace_from_yaml(client.api_client.ApiClient(), yaml_file) + + +@pytest.mark.e2e_operator_clusterwide +@pytest.mark.e2e_operator_multi_namespaces +def test_create_image_pull_secret_mdb_namespace( + namespace: str, mdb_namespace: str, operator_installation_config: Dict[str, str] +): + secret_name = operator_installation_config["registry.imagePullSecrets"] + data = read_secret(namespace, secret_name) + create_secret( + mdb_namespace, secret_name, data, type="kubernetes.io/dockerconfigjson" + ) + + +@pytest.mark.e2e_operator_clusterwide +@pytest.mark.e2e_operator_multi_namespaces +def test_create_om_in_separate_namespace(ops_manager: MongoDBOpsManager): + ops_manager.create_admin_secret() + ops_manager.backup_status().assert_reaches_phase( + Phase.Pending, msg_regexp=".*configuration is required for backup", timeout=900 + ) + ops_manager.get_om_tester().assert_healthiness() + + +@pytest.mark.e2e_operator_clusterwide +@pytest.mark.e2e_operator_multi_namespaces +def test_check_k8s_resources( + ops_manager: MongoDBOpsManager, ops_manager_namespace: str, namespace: str +): + """Verifying that all the K8s resources were created in an ops manager namespace""" + assert ops_manager.read_statefulset().metadata.namespace == ops_manager_namespace + assert ( + ops_manager.read_backup_statefulset().metadata.namespace + == ops_manager_namespace + ) + # api key secret is created in the Operator namespace for better access control + ops_manager.read_api_key_secret(namespace) + assert ops_manager.read_gen_key_secret().metadata.namespace == ops_manager_namespace + assert ( + ops_manager.read_appdb_generated_password_secret().metadata.namespace + == ops_manager_namespace + ) + + +@pytest.mark.e2e_operator_clusterwide +@pytest.mark.e2e_operator_multi_namespaces +def test_create_mdb_in_separate_namespace(mdb: MongoDB, mdb_namespace: str): + mdb.assert_reaches_phase(Phase.Running, timeout=350) + mdb.assert_connectivity() + assert mdb.read_statefulset().metadata.namespace == mdb_namespace + + +@pytest.mark.e2e_operator_clusterwide +@pytest.mark.e2e_operator_multi_namespaces +def test_upgrade_mdb(mdb: MongoDB): + mdb["spec"]["version"] = "4.2.2" + + mdb.update() + mdb.assert_abandons_phase(Phase.Running) + mdb.assert_reaches_phase(Phase.Running) + mdb.assert_connectivity() + mdb.tester().assert_version("4.2.2") + + +@pytest.mark.e2e_operator_clusterwide +@pytest.mark.e2e_operator_multi_namespaces +def test_delete_mdb(mdb: MongoDB): + mdb.delete() + + time.sleep(10) + with pytest.raises(client.rest.ApiException): + mdb.read_statefulset() + + +@pytest.mark.e2e_operator_multi_namespaces +def test_resources_on_unmanaged_namespaces_stay_cold(unmanaged_mdb: MongoDB): + """ + For an unmanaged resource, the status should not be updated! + """ + for i in range(10): + time.sleep(5) + + unmanaged_mdb.reload() + assert "status" not in unmanaged_mdb diff --git a/docker/mongodb-enterprise-tests/tests/operator/operator_partial_crd.py b/docker/mongodb-enterprise-tests/tests/operator/operator_partial_crd.py new file mode 100644 index 000000000..1378c898b --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/operator/operator_partial_crd.py @@ -0,0 +1,95 @@ +import pytest +from kubernetes import client +from kubetester.create_or_replace_from_yaml import create_or_replace_from_yaml +from pytest import fixture + +from kubetester.operator import Operator, delete_operator_crds, list_operator_crds + +# Dev note: remove all the CRDs before running the test locally! +from typing import Dict + + +@fixture(scope="module") +def ops_manager_and_mongodb_crds(): + """Installs OM and MDB CRDs only (we need to do this manually as Helm 3 doesn't support templating for CRDs""" + create_or_replace_from_yaml( + client.api_client.ApiClient(), "helm_chart/crds/mongodb.com_mongodb.yaml" + ) + create_or_replace_from_yaml( + client.api_client.ApiClient(), "helm_chart/crds/mongodb.com_opsmanagers.yaml" + ) + + +@fixture(scope="module") +def operator_only_ops_manager_and_mongodb( + ops_manager_and_mongodb_crds, + namespace: str, + operator_installation_config: Dict[str, str], +) -> Operator: + helm_args = operator_installation_config.copy() + helm_args["operator.watchedResources"] = "{opsmanagers,mongodb}" + + return Operator( + namespace=namespace, + helm_args=helm_args, + helm_options=["--skip-crds"], + ).install() + + +@fixture(scope="module") +def mongodb_crds(): + """Installs OM and MDB CRDs only (we need to do this manually as Helm 3 doesn't support templating for CRDs""" + create_or_replace_from_yaml( + client.api_client.ApiClient(), "helm_chart/crds/mongodb.com_mongodb.yaml" + ) + + +@fixture(scope="module") +def operator_only_mongodb( + mongodb_crds, + namespace: str, + operator_installation_config: Dict[str, str], +) -> Operator: + helm_args = operator_installation_config.copy() + helm_args["operator.watchedResources"] = "{mongodb}" + + return Operator( + namespace=namespace, + helm_args=helm_args, + helm_options=["--skip-crds"], + ).install() + + +@pytest.mark.e2e_operator_partial_crd +def test_install_operator_ops_manager_and_mongodb_only( + operator_only_ops_manager_and_mongodb: Operator, +): + """Note, that currently it's not possible to install OpsManager only as it requires MongoDB resources + (it watches them internally)""" + operator_only_ops_manager_and_mongodb.assert_is_running() + + +@pytest.mark.e2e_operator_partial_crd +def test_only_ops_manager_and_mongodb_crds_exist(): + operator_crds = list_operator_crds() + assert len(operator_crds) == 2 + assert operator_crds[0].metadata.name == "mongodb.mongodb.com" + assert operator_crds[1].metadata.name == "opsmanagers.mongodb.com" + + +@pytest.mark.e2e_operator_partial_crd +def test_remove_operator_and_crds(operator_only_ops_manager_and_mongodb: Operator): + delete_operator_crds() + operator_only_ops_manager_and_mongodb.uninstall() + + +@pytest.mark.e2e_operator_partial_crd +def test_install_operator_mongodb_only(operator_only_mongodb: Operator): + operator_only_mongodb.assert_is_running() + + +@pytest.mark.e2e_operator_partial_crd +def test_only_mongodb_and_users_crds_exists(): + operator_crds = list_operator_crds() + assert len(operator_crds) == 1 + assert operator_crds[0].metadata.name == "mongodb.mongodb.com" diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/__init__.py b/docker/mongodb-enterprise-tests/tests/opsmanager/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/backup_snapshot_schedule_tests.py b/docker/mongodb-enterprise-tests/tests/opsmanager/backup_snapshot_schedule_tests.py new file mode 100644 index 000000000..3708877d4 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/backup_snapshot_schedule_tests.py @@ -0,0 +1,207 @@ +from typing import Dict + +import pytest +from kubernetes.client import ApiException +from pytest import fixture + +from kubetester import create_or_update, try_load +from kubetester.kubetester import ( + fixture as yaml_fixture, +) +from kubetester.mongodb import Phase, MongoDB +from kubetester.omtester import OMTester +from kubetester.opsmanager import MongoDBOpsManager +from tests.opsmanager.conftest import ensure_ent_version + + +class BackupSnapshotScheduleTests: + """Test executes snapshot schedule tests on top of existing ops_manager. + + This test class is intended to be reused by inheriting from it and overriding fixtures: + mdb - for providing base MongoDB resource + mdb_version - for customizing MongoDB versions. + om_project_name - for customizing project name - for multiple tests running on top of single OM. + """ + + @fixture + def mdb(self, ops_manager: MongoDBOpsManager): + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=ops_manager.namespace, + name="mdb-backup-snapshot-schedule", + ) + + try_load(resource) + return resource + + @fixture + def mdb_version(self, custom_mdb_version): + return custom_mdb_version + + @fixture + def om_project_name(self): + return "backupSnapshotSchedule" + + def test_create_mdb_with_backup_enabled_and_configured_snapshot_schedule( + self, + mdb: MongoDB, + ops_manager: MongoDBOpsManager, + mdb_version: str, + om_project_name: str, + ): + mdb.configure(ops_manager, om_project_name) + + mdb["spec"]["version"] = ensure_ent_version(mdb_version) + mdb.configure_backup(mode="enabled") + mdb["spec"]["backup"]["snapshotSchedule"] = { + "fullIncrementalDayOfWeek": "MONDAY", + } + create_or_update(mdb) + mdb.assert_reaches_phase(Phase.Reconciling) + mdb.assert_reaches_phase(Phase.Running) + mdb.assert_backup_reaches_status("STARTED") + + self.assert_snapshot_schedule_in_ops_manager( + mdb.get_om_tester(), + { + "fullIncrementalDayOfWeek": "MONDAY", + }, + ) + + @pytest.mark.skip( + reason="Backup termination is not handled properly by the operator: https://jira.mongodb.org/browse/CLOUDP-149270" + ) + def test_when_backup_terminated_snapshot_schedule_is_ignored(self, mdb: MongoDB): + mdb.configure_backup(mode="disabled") + mdb.update() + mdb.assert_reaches_phase(Phase.Reconciling) + mdb.assert_reaches_phase(Phase.Running) + mdb.assert_backup_reaches_status("STOPPED") + + mdb.load() + mdb.configure_backup(mode="terminated") + mdb.update() + mdb.assert_reaches_phase(Phase.Reconciling) + mdb.assert_reaches_phase(Phase.Running) + mdb.assert_backup_reaches_status("TERMINATING") + + try: + mdb.get_om_tester().api_read_backup_snapshot_schedule() + assert False, "exception about missing backup configuration should be raised" + except Exception: + pass + + mdb.configure_backup(mode="enabled") + mdb.update() + mdb.assert_reaches_phase(Phase.Reconciling) + mdb.assert_reaches_phase(Phase.Running) + mdb.assert_backup_reaches_status("STARTED") + + def test_stop_backup_and_change_snapshot_schedule(self, mdb: MongoDB): + mdb.configure_backup(mode="disabled") + self.update_snapshot_schedule( + mdb, + { + "fullIncrementalDayOfWeek": "WEDNESDAY", + }, + ) + mdb.assert_backup_reaches_status("STOPPED") + + self.assert_snapshot_schedule_in_ops_manager( + mdb.get_om_tester(), + { + "fullIncrementalDayOfWeek": "WEDNESDAY", + }, + ) + + def test_enable_backup_and_change_snapshot_schedule(self, mdb: MongoDB): + mdb.configure_backup(mode="enabled") + self.update_snapshot_schedule( + mdb, + { + "fullIncrementalDayOfWeek": "TUESDAY", + }, + ) + mdb.assert_backup_reaches_status("STARTED") + + self.assert_snapshot_schedule_in_ops_manager( + mdb.get_om_tester(), + { + "fullIncrementalDayOfWeek": "TUESDAY", + }, + ) + + def test_only_one_field_is_set(self, mdb: MongoDB): + prev_snapshot_schedule = mdb.get_om_tester().api_read_backup_snapshot_schedule() + + self.update_snapshot_schedule( + mdb, + { + "fullIncrementalDayOfWeek": "THURSDAY", + }, + ) + + expected_snapshot_schedule = dict(prev_snapshot_schedule) + expected_snapshot_schedule["fullIncrementalDayOfWeek"] = "THURSDAY" + + self.assert_snapshot_schedule_in_ops_manager(mdb.get_om_tester(), expected_snapshot_schedule) + + def test_check_all_fields_are_set(self, mdb: MongoDB): + self.update_and_assert_snapshot_schedule( + mdb, + { + "snapshotIntervalHours": 12, + "snapshotRetentionDays": 3, + "dailySnapshotRetentionDays": 5, + "weeklySnapshotRetentionWeeks": 4, + "monthlySnapshotRetentionMonths": 5, + "pointInTimeWindowHours": 6, + "referenceHourOfDay": 1, + "referenceMinuteOfHour": 3, + "fullIncrementalDayOfWeek": "THURSDAY", + }, + ) + + def test_validations(self, mdb: MongoDB): + # we're smoke-testing if any of the CRD validations works + try: + self.update_snapshot_schedule( + mdb, + { + "fullIncrementalDayOfWeek": "January", + }, + ) + except ApiException as e: + assert e.status == 422 # "Unprocessable Entity" + pass + + # revert state back to running + self.update_snapshot_schedule( + mdb, + { + "fullIncrementalDayOfWeek": "WEDNESDAY", + }, + ) + + @staticmethod + def update_snapshot_schedule(mdb: MongoDB, snapshot_schedule: Dict): + last_transition = mdb.get_status_last_transition_time() + + mdb["spec"]["backup"]["snapshotSchedule"] = snapshot_schedule + mdb.update() + + mdb.assert_state_transition_happens(last_transition) + mdb.assert_reaches_phase(Phase.Running) + + @staticmethod + def assert_snapshot_schedule_in_ops_manager(om_tester: OMTester, expected_snapshot_schedule: Dict): + snapshot_schedule = om_tester.api_read_backup_snapshot_schedule() + + for k, v in expected_snapshot_schedule.items(): + assert k in snapshot_schedule + assert snapshot_schedule[k] == v + + @staticmethod + def update_and_assert_snapshot_schedule(mdb: MongoDB, snapshot_schedule: Dict): + BackupSnapshotScheduleTests.update_snapshot_schedule(mdb, snapshot_schedule) + BackupSnapshotScheduleTests.assert_snapshot_schedule_in_ops_manager(mdb.get_om_tester(), snapshot_schedule) diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/conftest.py b/docker/mongodb-enterprise-tests/tests/opsmanager/conftest.py new file mode 100644 index 000000000..a697d84f2 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/conftest.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 + +import os + +from pytest import fixture, skip +from kubetester.opsmanager import MongoDBOpsManager + + +def pytest_runtest_setup(item): + """This allows to automatically install the default Operator before running any test""" + if ( + "default_operator" not in item.fixturenames + and "operator_with_monitored_appdb" not in item.fixturenames + ): + item.fixturenames.insert(0, "default_operator") + + +@fixture(scope="module") +def custom_om_prev_version() -> str: + """Returns a CUSTOM_OM_PREV_VERSION for OpsManager to be created/upgraded.""" + return os.getenv("CUSTOM_OM_PREV_VERSION", "4.4.15") + + +@fixture(scope="module") +def custom_mdb_prev_version() -> str: + """Returns a CUSTOM_MDB_PREV_VERSION for Mongodb to be created/upgraded to for testing. + Used for backup mainly (to test backup for different mdb versions). + Defaults to 4.4.24 (simplifies testing locally)""" + return os.getenv("CUSTOM_MDB_PREV_VERSION", "4.4.24") + + +def ensure_ent_version(mdb_version: str) -> str: + if "-ent" not in mdb_version: + return mdb_version + "-ent" + return mdb_version + + +@fixture(scope="module") +def gen_key_resource_version(ops_manager: MongoDBOpsManager) -> str: + secret = ops_manager.read_gen_key_secret() + return secret.metadata.resource_version + + +@fixture(scope="module") +def admin_key_resource_version(ops_manager: MongoDBOpsManager) -> str: + secret = ops_manager.read_api_key_secret() + return secret.metadata.resource_version + + +@fixture +def skip_if_om5(custom_version: str): + """ + When including this fixture on a test, the test will be skipped, + if the "custom_version" is set to OM5.0 + """ + if custom_version.startswith("5."): + raise skip("Skipping on OM5.0 tests") diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/ca-tls-full-chain.crt b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/ca-tls-full-chain.crt new file mode 100644 index 000000000..5dd75939e --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/ca-tls-full-chain.crt @@ -0,0 +1,131 @@ +-----BEGIN CERTIFICATE----- +MIIDDjCCAfagAwIBAgIJAPifkd/1ZoEXMA0GCSqGSIb3DQEBCwUAMA0xCzAJBgNV +BAYTAklSMB4XDTIzMDExMDEzNDMwMVoXDTMzMDEwNzEzNDMwMVowDTELMAkGA1UE +BhMCSVIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCzsVQpiIqdBO87 +SVJsgXjJ7paWsnXDCSmFjOSELrGw1l2pnoYxc9IDysP4rsdIw3THWp3w3Wv/kqQf +PD8cY6r4RMbPWn6lN3/vGfgQz3XaosfobLuJFZRORZQY870FH21nBE8iNqapsxPy +NVu70TyqbThmM1bUZ0sB+RIaBUEY0e+M7bVxxFdt1t+dmNdmJg0R4RuJWz/UbUYo +wyNhqTe98NE7SKGFti7q3mhOEg1s6zvtqO6EILTg0i0Ndf8rKgJVds6h+HRUFoxt +rssoxwycDxPhCKlRFMCzYmPXE+PGJ5BXXctlj8Wh5gGwQZ8pMboS/GqFoRQfW1tI +HwvVnednAgMBAAGjcTBvMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFI10Lxi6 +GKcBbc0+zqDXFRepKpEbMD0GA1UdIwQ2MDSAFI10Lxi6GKcBbc0+zqDXFRepKpEb +oRGkDzANMQswCQYDVQQGEwJJUoIJAPifkd/1ZoEXMA0GCSqGSIb3DQEBCwUAA4IB +AQCdimbB5spMgpIv+NPGoeJTXWlojWZ9SBvYTPG5wGjcoPMvM81f3+XRrXPfyl+e +iozVDYL8YE3IGPtqlSYdlS4gg43+sNvXCv0O/dT/T/587LuRLP3iYCyVcn/1UcXJ +IDfYUJR6JVkKtI6Rk0WDTuDmfUAmn5S35Ll8fvZnC2WcWSRqnYhKAqcmac/t89AX +M9mhOT3qOKMfkgT94wgtz5DTR9lAEAVfEgrmQ+zbHDfAfzPD7fDZ5WXIhhU15Wkp +0My5C/ob54EonfnxQCqc/Xge6oSRmNnPIMBhBffPvDpJK23MCIXm5yyWaYhqD3M+ +K3ASw6isSK98ICi/ujWSHWC0 +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIF9zCCBN+gAwIBAgIQDTiokZJgKoaLCOzt0K3ulzANBgkqhkiG9w0BAQsFADBG +MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRUwEwYDVQQLEwxTZXJ2ZXIg +Q0EgMUIxDzANBgNVBAMTBkFtYXpvbjAeFw0yMjA2MjIwMDAwMDBaFw0yMzA3MjEy +MzU5NTlaMCAxHjAcBgNVBAMTFWRvd25sb2Fkcy5tb25nb2RiLmNvbTCCASIwDQYJ +KoZIhvcNAQEBBQADggEPADCCAQoCggEBANZyoobPRnuB0Y8g1MNre1ptStvETcBw +bXh3g7QtgEoFUryEQPnrkPQJUh7vWmVAhwyfYb3NjoGPoUfCC7vc2kVqXUfydV45 +/V8mUhs/8dbpZXn//sfoFum5PrbX+GydWrvtC17USkWto+Miw046dqEntmill7J4 +znR3EHmvNZiXMkf67QCyBjpTT71LYecJFjKVxjUdC3xe8TOsCsx+2IPMVNvIeqxt +G3aW0rPxuFlQ7oIyXgdRXtlD5vdoPioxk+P2vj1gmKtTGbKUTDqac2r2cwP0uuGo +xV0gN/JGmufEu6bcngIj2DXpMEtBgHcYXk8IgQpe4QLiRdrgaK+DMz8CAwEAAaOC +AwUwggMBMB8GA1UdIwQYMBaAFFmkZgZSoHuVkjyjlAcnlnRb+T3QMB0GA1UdDgQW +BBQQ/1E/6lSuAqx97QEK8lVn0L81bjA1BgNVHREELjAsghVkb3dubG9hZHMubW9u +Z29kYi5jb22CE2Rvd25sb2Fkcy4xMGdlbi5jb20wDgYDVR0PAQH/BAQDAgWgMB0G +A1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjA9BgNVHR8ENjA0MDKgMKAuhixo +dHRwOi8vY3JsLnNjYTFiLmFtYXpvbnRydXN0LmNvbS9zY2ExYi0xLmNybDATBgNV +HSAEDDAKMAgGBmeBDAECATB1BggrBgEFBQcBAQRpMGcwLQYIKwYBBQUHMAGGIWh0 +dHA6Ly9vY3NwLnNjYTFiLmFtYXpvbnRydXN0LmNvbTA2BggrBgEFBQcwAoYqaHR0 +cDovL2NydC5zY2ExYi5hbWF6b250cnVzdC5jb20vc2NhMWIuY3J0MAwGA1UdEwEB +/wQCMAAwggF+BgorBgEEAdZ5AgQCBIIBbgSCAWoBaAB2AOg+0No+9QY1MudXKLyJ +a8kD08vREWvs62nhd31tBr1uAAABgYyc2+4AAAQDAEcwRQIgJcz+XaaaFAcPiteo +AaunoWepexB4vim6CmH5RjUxp9gCIQCD4rMXUFwiUbwrCvzYDNxoLmApMCWOwxgF +Sg0KpEFcyQB2ADXPGRu/sWxXvw+tTG1Cy7u2JyAmUeo/4SrvqAPDO9ZMAAABgYyc +3CEAAAQDAEcwRQIhAMKh1TL/yOlbSCIGf1NBMy3y+TLSjg672TAbxks+i/w/AiBX +RMleFwVvl0rfn7mtjx4sNg/KV7jxGWlSsE9HVE59tQB2ALc++yTfnE26dfI5xbpY +9Gxd/ELPep81xJ4dCYEl7bSZAAABgYyc3CQAAAQDAEcwRQIhAMcmDntNQU28qZd0 +8SaSdXKsb6DxJC/aZF+aYCi2dv14AiBHPCw6fOGgPSYM4yhs5cdqwK9wUH23G+22 +yYj5PrHTtTANBgkqhkiG9w0BAQsFAAOCAQEAooTXKMDE9j5bXGpGB8pIyYcDJVZK +Xe14ClMwJdcIr2dJM1HY++2+x9taTT1+C/bxwOfU+S5O68cGTcSY5/HZKbhIMHo4 +oGBzpfREIxMLl9n74USGBbf7h8bI+m7wJK+68HhH3La6Hd1tic6DkPmpVxrRB5ux +as1tbGN26JON/UqKxUZwcx+szhdEApWkmEJBm9S3A6/jN/yIPW5IiS084jRSk9YG +p6TInSGnHnf3M5CnlSUEG1tgMNl1tbvAf1NIsAdMSDwahTtZqO39N8XD7X/IriT2 +z20gl9DxUae6etCoGbo44+giCkqDfIeZFcabLqJiD8bNz6wj6ac17pysXQ== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIESTCCAzGgAwIBAgITBn+UV4WH6Kx33rJTMlu8mYtWDTANBgkqhkiG9w0BAQsF +ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6 +b24gUm9vdCBDQSAxMB4XDTE1MTAyMjAwMDAwMFoXDTI1MTAxOTAwMDAwMFowRjEL +MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEVMBMGA1UECxMMU2VydmVyIENB +IDFCMQ8wDQYDVQQDEwZBbWF6b24wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK +AoIBAQDCThZn3c68asg3Wuw6MLAd5tES6BIoSMzoKcG5blPVo+sDORrMd4f2AbnZ +cMzPa43j4wNxhplty6aUKk4T1qe9BOwKFjwK6zmxxLVYo7bHViXsPlJ6qOMpFge5 +blDP+18x+B26A0piiQOuPkfyDyeR4xQghfj66Yo19V+emU3nazfvpFA+ROz6WoVm +B5x+F2pV8xeKNR7u6azDdU5YVX1TawprmxRC1+WsAYmz6qP+z8ArDITC2FMVy2fw +0IjKOtEXc/VfmtTFch5+AfGYMGMqqvJ6LcXiAhqG5TI+Dr0RtM88k+8XUBCeQ8IG +KuANaL7TiItKZYxK1MMuTJtV9IblAgMBAAGjggE7MIIBNzASBgNVHRMBAf8ECDAG +AQH/AgEAMA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUWaRmBlKge5WSPKOUByeW +dFv5PdAwHwYDVR0jBBgwFoAUhBjMhTTsvAyUlC4IWZzHshBOCggwewYIKwYBBQUH +AQEEbzBtMC8GCCsGAQUFBzABhiNodHRwOi8vb2NzcC5yb290Y2ExLmFtYXpvbnRy +dXN0LmNvbTA6BggrBgEFBQcwAoYuaHR0cDovL2NydC5yb290Y2ExLmFtYXpvbnRy +dXN0LmNvbS9yb290Y2ExLmNlcjA/BgNVHR8EODA2MDSgMqAwhi5odHRwOi8vY3Js +LnJvb3RjYTEuYW1hem9udHJ1c3QuY29tL3Jvb3RjYTEuY3JsMBMGA1UdIAQMMAow +CAYGZ4EMAQIBMA0GCSqGSIb3DQEBCwUAA4IBAQCFkr41u3nPo4FCHOTjY3NTOVI1 +59Gt/a6ZiqyJEi+752+a1U5y6iAwYfmXss2lJwJFqMp2PphKg5625kXg8kP2CN5t +6G7bMQcT8C8xDZNtYTd7WPD8UZiRKAJPBXa30/AbwuZe0GaFEQ8ugcYQgSn+IGBI +8/LwhBNTZTUVEWuCUUBVV18YtbAiPq3yXqMB48Oz+ctBWuZSkbvkNodPLamkB2g1 +upRyzQ7qDn1X8nn8N8V7YJ6y68AtkHcNSRAnpTitxBKjtKPISLMVCx7i4hncxHZS +yLyKQXhw2W2Xs0qLeC1etA+jTGDK4UfLeC0SF7FSi8o5LL21L8IzApar2pR/ +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIEkjCCA3qgAwIBAgITBn+USionzfP6wq4rAfkI7rnExjANBgkqhkiG9w0BAQsF +ADCBmDELMAkGA1UEBhMCVVMxEDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNj +b3R0c2RhbGUxJTAjBgNVBAoTHFN0YXJmaWVsZCBUZWNobm9sb2dpZXMsIEluYy4x +OzA5BgNVBAMTMlN0YXJmaWVsZCBTZXJ2aWNlcyBSb290IENlcnRpZmljYXRlIEF1 +dGhvcml0eSAtIEcyMB4XDTE1MDUyNTEyMDAwMFoXDTM3MTIzMTAxMDAwMFowOTEL +MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJv +b3QgQ0EgMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALJ4gHHKeNXj +ca9HgFB0fW7Y14h29Jlo91ghYPl0hAEvrAIthtOgQ3pOsqTQNroBvo3bSMgHFzZM +9O6II8c+6zf1tRn4SWiw3te5djgdYZ6k/oI2peVKVuRF4fn9tBb6dNqcmzU5L/qw +IFAGbHrQgLKm+a/sRxmPUDgH3KKHOVj4utWp+UhnMJbulHheb4mjUcAwhmahRWa6 +VOujw5H5SNz/0egwLX0tdHA114gk957EWW67c4cX8jJGKLhD+rcdqsq08p8kDi1L +93FcXmn/6pUCyziKrlA4b9v7LWIbxcceVOF34GfID5yHI9Y/QCB/IIDEgEw+OyQm +jgSubJrIqg0CAwEAAaOCATEwggEtMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/ +BAQDAgGGMB0GA1UdDgQWBBSEGMyFNOy8DJSULghZnMeyEE4KCDAfBgNVHSMEGDAW +gBScXwDfqgHXMCs4iKK4bUqc8hGRgzB4BggrBgEFBQcBAQRsMGowLgYIKwYBBQUH +MAGGImh0dHA6Ly9vY3NwLnJvb3RnMi5hbWF6b250cnVzdC5jb20wOAYIKwYBBQUH +MAKGLGh0dHA6Ly9jcnQucm9vdGcyLmFtYXpvbnRydXN0LmNvbS9yb290ZzIuY2Vy +MD0GA1UdHwQ2MDQwMqAwoC6GLGh0dHA6Ly9jcmwucm9vdGcyLmFtYXpvbnRydXN0 +LmNvbS9yb290ZzIuY3JsMBEGA1UdIAQKMAgwBgYEVR0gADANBgkqhkiG9w0BAQsF +AAOCAQEAYjdCXLwQtT6LLOkMm2xF4gcAevnFWAu5CIw+7bMlPLVvUOTNNWqnkzSW +MiGpSESrnO09tKpzbeR/FoCJbM8oAxiDR3mjEH4wW6w7sGDgd9QIpuEdfF7Au/ma +eyKdpwAJfqxGF4PcnCZXmTA5YpaP7dreqsXMGz7KQ2hsVxa81Q4gLv7/wmpdLqBK +bRRYh5TmOTFffHPLkIhqhBGWJ6bt2YFGpn6jcgAKUj6DiAdjd4lpFw85hdKrCEVN +0FE6/V1dN2RMfjCyVSRCnTawXZwXgWHxyvkQAiSr6w10kY17RSlQOYiypok1JR4U +akcjMS9cmvqtmg5iUaQqqcT5NJ0hGA== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIEdTCCA12gAwIBAgIJAKcOSkw0grd/MA0GCSqGSIb3DQEBCwUAMGgxCzAJBgNV +BAYTAlVTMSUwIwYDVQQKExxTdGFyZmllbGQgVGVjaG5vbG9naWVzLCBJbmMuMTIw +MAYDVQQLEylTdGFyZmllbGQgQ2xhc3MgMiBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0 +eTAeFw0wOTA5MDIwMDAwMDBaFw0zNDA2MjgxNzM5MTZaMIGYMQswCQYDVQQGEwJV +UzEQMA4GA1UECBMHQXJpem9uYTETMBEGA1UEBxMKU2NvdHRzZGFsZTElMCMGA1UE +ChMcU3RhcmZpZWxkIFRlY2hub2xvZ2llcywgSW5jLjE7MDkGA1UEAxMyU3RhcmZp +ZWxkIFNlcnZpY2VzIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IC0gRzIwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDVDDrEKvlO4vW+GZdfjohTsR8/ +y8+fIBNtKTrID30892t2OGPZNmCom15cAICyL1l/9of5JUOG52kbUpqQ4XHj2C0N +Tm/2yEnZtvMaVq4rtnQU68/7JuMauh2WLmo7WJSJR1b/JaCTcFOD2oR0FMNnngRo +Ot+OQFodSk7PQ5E751bWAHDLUu57fa4657wx+UX2wmDPE1kCK4DMNEffud6QZW0C +zyyRpqbn3oUYSXxmTqM6bam17jQuug0DuDPfR+uxa40l2ZvOgdFFRjKWcIfeAg5J +Q4W2bHO7ZOphQazJ1FTfhy/HIrImzJ9ZVGif/L4qL8RVHHVAYBeFAlU5i38FAgMB +AAGjgfAwge0wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0O +BBYEFJxfAN+qAdcwKziIorhtSpzyEZGDMB8GA1UdIwQYMBaAFL9ft9HO3R+G9FtV +rNzXEMIOqYjnME8GCCsGAQUFBwEBBEMwQTAcBggrBgEFBQcwAYYQaHR0cDovL28u +c3MyLnVzLzAhBggrBgEFBQcwAoYVaHR0cDovL3guc3MyLnVzL3guY2VyMCYGA1Ud +HwQfMB0wG6AZoBeGFWh0dHA6Ly9zLnNzMi51cy9yLmNybDARBgNVHSAECjAIMAYG +BFUdIAAwDQYJKoZIhvcNAQELBQADggEBACMd44pXyn3pF3lM8R5V/cxTbj5HD9/G +VfKyBDbtgB9TxF00KGu+x1X8Z+rLP3+QsjPNG1gQggL4+C/1E2DUBc7xgQjB3ad1 +l08YuW3e95ORCLp+QCztweq7dp4zBncdDQh/U90bZKuCJ/Fp1U1ervShw3WnWEQt +8jxwmKy6abaVd38PMV4s/KCHOkdp8Hlf9BRUpJVeEXgSYCfOn8J3/yNTd126/+pZ +59vPr5KW7ySaNRB6nJHGDn2Z9j8Z3/VyVOEVqQdZe4O/Ui5GjLIAZHYcSNPYeehu +VsyuLAOQ1xk4meTKCRlb/weWsKh/NEnfVqn3sF/tM+2MR7cwA130A4w= +-----END CERTIFICATE----- diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/ca-tls.crt b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/ca-tls.crt new file mode 100644 index 000000000..2dfe19859 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/ca-tls.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDDjCCAfagAwIBAgIJAPifkd/1ZoEXMA0GCSqGSIb3DQEBCwUAMA0xCzAJBgNV +BAYTAklSMB4XDTIzMDExMDEzNDMwMVoXDTMzMDEwNzEzNDMwMVowDTELMAkGA1UE +BhMCSVIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCzsVQpiIqdBO87 +SVJsgXjJ7paWsnXDCSmFjOSELrGw1l2pnoYxc9IDysP4rsdIw3THWp3w3Wv/kqQf +PD8cY6r4RMbPWn6lN3/vGfgQz3XaosfobLuJFZRORZQY870FH21nBE8iNqapsxPy +NVu70TyqbThmM1bUZ0sB+RIaBUEY0e+M7bVxxFdt1t+dmNdmJg0R4RuJWz/UbUYo +wyNhqTe98NE7SKGFti7q3mhOEg1s6zvtqO6EILTg0i0Ndf8rKgJVds6h+HRUFoxt +rssoxwycDxPhCKlRFMCzYmPXE+PGJ5BXXctlj8Wh5gGwQZ8pMboS/GqFoRQfW1tI +HwvVnednAgMBAAGjcTBvMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFI10Lxi6 +GKcBbc0+zqDXFRepKpEbMD0GA1UdIwQ2MDSAFI10Lxi6GKcBbc0+zqDXFRepKpEb +oRGkDzANMQswCQYDVQQGEwJJUoIJAPifkd/1ZoEXMA0GCSqGSIb3DQEBCwUAA4IB +AQCdimbB5spMgpIv+NPGoeJTXWlojWZ9SBvYTPG5wGjcoPMvM81f3+XRrXPfyl+e +iozVDYL8YE3IGPtqlSYdlS4gg43+sNvXCv0O/dT/T/587LuRLP3iYCyVcn/1UcXJ +IDfYUJR6JVkKtI6Rk0WDTuDmfUAmn5S35Ll8fvZnC2WcWSRqnYhKAqcmac/t89AX +M9mhOT3qOKMfkgT94wgtz5DTR9lAEAVfEgrmQ+zbHDfAfzPD7fDZ5WXIhhU15Wkp +0My5C/ob54EonfnxQCqc/Xge6oSRmNnPIMBhBffPvDpJK23MCIXm5yyWaYhqD3M+ +K3ASw6isSK98ICi/ujWSHWC0 +-----END CERTIFICATE----- diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/ca-tls.key b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/ca-tls.key new file mode 100644 index 000000000..7cd89c24d --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/ca-tls.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCzsVQpiIqdBO87 +SVJsgXjJ7paWsnXDCSmFjOSELrGw1l2pnoYxc9IDysP4rsdIw3THWp3w3Wv/kqQf +PD8cY6r4RMbPWn6lN3/vGfgQz3XaosfobLuJFZRORZQY870FH21nBE8iNqapsxPy +NVu70TyqbThmM1bUZ0sB+RIaBUEY0e+M7bVxxFdt1t+dmNdmJg0R4RuJWz/UbUYo +wyNhqTe98NE7SKGFti7q3mhOEg1s6zvtqO6EILTg0i0Ndf8rKgJVds6h+HRUFoxt +rssoxwycDxPhCKlRFMCzYmPXE+PGJ5BXXctlj8Wh5gGwQZ8pMboS/GqFoRQfW1tI +HwvVnednAgMBAAECggEAeZkth/GjQ4B8V5VVlqHC2HuBIjdf43zGwV5HoX9rtWxK +86aXzs0+uFw1Y4r6xq2lz+XtbXqZQ9i7AXwmhRKZNupr0xO9EhbNl0LukImjijGP +sCQsgCa/Nnx1LLF8HwRWZ1kOJ+vtuna5r7UV/7InKHlCqj5hqti/dHVVH5Cgrab4 +4HBTAnwX2YVKaU163GKTTJyVGzGwN9AOONnLuGpGs8j700McKe8Ydn461W5TKdoG +RbPcS6lbQztNE6Nn8oXNuFRsm8Wf9ZwvPqypQqgv0CIYxxUt4EEtMngNWQgJhPAc +uZr5htR3+rrFi+exlfm/gn6UZ2GWxZtN+w1/xZSa8QKBgQDcGkFg5IQUuiwTub9L +Ki3ShApfIqkdSsF26T3PoeF9mASzQslXxQSj6dMdFPlwbq6baIRxygmkA2LzBgDp +uUvPxoRKQGcpddzmsYLQIht43S1R12kN9JR3jcaIH4dxYecTTyjgdvBWYm1aGQwW +Nga8GMIl/CnrEeOkwqkgmvrdaQKBgQDQ/+BZOQUFtQdViQEjxCZFbCJs29v+c552 +rVFEtH7DbIymUDKWeqlMNt4ibnSL+Z4pmUgaWE7GlRdwNBM/9ACWiRfh10J0Ybyi +rS5yHVGK1759N9H4pYKUngUCYUihKlzYKB+/43QG+WbQ/INm2TUho05s7rOo81F3 +q172iuB0TwKBgQCTJMZSZVLbnH69DS+Sq3cIxpc8dKqEV6awvUtCVOGvmgKCaQK7 +t43bmwU06wG7JXN7l8r7W2tIh68N8xSHLAY/uGJWVWniMNZmL4PZawPcsFiM3ypv +VvQuXMy90f41UZMuuHwGW91ektyyIA6RhrrH4vFgfYz0hvgd/LkegB14CQKBgQCf +vRIR35zREd27KG2wknjV0qI1JY1tW50gA7P7mSDR6KNPcjhX/wRqdf0tv9JgMbcL +AFa1nA0JhmZVodecp7fTVpDkUgw+u3zbsRWwrmvmfKLhPcrECmxVfrlBam2CkMhJ +hdFObl/9/Jzy2izsbNNJFHIanA7A8MexeU+pi9elzQKBgAE5mh67F00AQjC8zbq6 +332EUeAZNcDNdiBbX7P6PVB7BCyjNV4+33/4U8nvjR0aXvtrVWLaxXP5l8yo9Z8E +Uk7xhRR8z1IX2IyxMmLOYSfQnDu1PWDGYiQcvSEYpTsDpwBIRgFt0LgvScjgm1mM +4zpzDXaoMZHsUFCgX6BMtUn8 +-----END PRIVATE KEY----- diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/downloads.mongodb.com.chained+root.crt b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/downloads.mongodb.com.chained+root.crt new file mode 100644 index 000000000..89562a069 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/downloads.mongodb.com.chained+root.crt @@ -0,0 +1,78 @@ +-----BEGIN CERTIFICATE----- +MIIFnzCCBIegAwIBAgIQDuqCvyfH3Q13NSQK0X77FDANBgkqhkiG9w0BAQsFADBG +MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRUwEwYDVQQLEwxTZXJ2ZXIg +Q0EgMUIxDzANBgNVBAMTBkFtYXpvbjAeFw0yMDA3MjYwMDAwMDBaFw0yMTA4MjYx +MjAwMDBaMCAxHjAcBgNVBAMTFWRvd25sb2Fkcy5tb25nb2RiLmNvbTCCASIwDQYJ +KoZIhvcNAQEBBQADggEPADCCAQoCggEBAL3hiqdT/B4M++HrnEVhmKvA3Jg1/1uI +YMKuIzL53AjdYfeHojw0Kx7EMkPxFTz1hztivJTDdXhd3FiiZDKm10OgUFHHaqLZ +MVdWpSRL8ftmMvKnN+JeN0IFst3+wh5r2hQEnPLPpFZy+O1DHaep8YlaPRSN5QA+ +UTraj6JcRsUcjEyXVtrX5FOxRVNw8eYwzAzktdGAy09OekO5VRmZmKoNOXPfMQQ2 +YiJNboKrEc9u5fN28cWJhO9eC+Of21qIVG2/Uln2823ZGWTmZ4bXi7Vg+UruiNNo +pUHQx6O/QIVLtom+dh7x3XszCUlZHwpnOlV2zdmp5srIjyu3XJVtTY8CAwEAAaOC +Aq0wggKpMB8GA1UdIwQYMBaAFFmkZgZSoHuVkjyjlAcnlnRb+T3QMB0GA1UdDgQW +BBQpx6kAjV7Otgz6vi0cdL4LnMQALTBLBgNVHREERDBCghVkb3dubG9hZHMubW9u +Z29kYi5jb22CEmZhc3RkbC5tb25nb2RiLm9yZ4IVZG93bmxvYWRzLm1vbmdvZGIu +b3JnMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUH +AwIwOwYDVR0fBDQwMjAwoC6gLIYqaHR0cDovL2NybC5zY2ExYi5hbWF6b250cnVz +dC5jb20vc2NhMWIuY3JsMCAGA1UdIAQZMBcwCwYJYIZIAYb9bAECMAgGBmeBDAEC +ATB1BggrBgEFBQcBAQRpMGcwLQYIKwYBBQUHMAGGIWh0dHA6Ly9vY3NwLnNjYTFi +LmFtYXpvbnRydXN0LmNvbTA2BggrBgEFBQcwAoYqaHR0cDovL2NydC5zY2ExYi5h +bWF6b250cnVzdC5jb20vc2NhMWIuY3J0MAwGA1UdEwEB/wQCMAAwggEFBgorBgEE +AdZ5AgQCBIH2BIHzAPEAdwD2XJQv0XcwIhRUGAgwlFaO400TGTO/3wwvIAvMTvFk +4wAAAXOIwjoPAAAEAwBIMEYCIQCO4BXmrdIGgh9DzIIxTh3vbUEX1c5ACGVhOlKy +kOwGMgIhAI89gFYczRzYZBWMCu4GYsZNyCqdnPQYKGaSAl8V7j0hAHYAXNxDkv7m +q0VEsV6a1FbmEDf71fpH3KFzlLJe5vbHDsoAAAFziMI6PAAABAMARzBFAiEAiGfq +xNmVD0l5igz6lwZsbZIVOuRzI9MRrqKgN52Ok8oCIAtg+SFfP3grDkYjPxqErjat +mX6ZowWVnyeLPL9SWrlhMA0GCSqGSIb3DQEBCwUAA4IBAQAwlVcR5ONm1lTKIo6v +k9eq0AJ/CpPlGB8ZtDmeJ8+kbHe89o2eSC9jcDdZjBYGQ1zVSzwj+VJ46FZ1rvd8 +6jTR3+jhN3JkxjmCbW6JkXJo+JQuOWf1c4o7+dFqzpBaV1bHDB8b+PWkkLlf9hOx +sPadHt9KTURP65/KK/G5bMvoFQnview7RJxILhr55D0YnpDYWVtVVSS6Nt2siORf +C8YFa3xsMBTWXzzPjhUiEC/kz2Hhru4rNss/eGh5VA13xonT+ohgMgncj3SXMWOu +qYT9XPG+/+i2m5dtzQqDe46AgM+zsejyyrdeJ7QYNyNIxqIH56wyvOSrJlQLuCqI +4L9U +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIESTCCAzGgAwIBAgITBn+UV4WH6Kx33rJTMlu8mYtWDTANBgkqhkiG9w0BAQsF +ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6 +b24gUm9vdCBDQSAxMB4XDTE1MTAyMjAwMDAwMFoXDTI1MTAxOTAwMDAwMFowRjEL +MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEVMBMGA1UECxMMU2VydmVyIENB +IDFCMQ8wDQYDVQQDEwZBbWF6b24wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK +AoIBAQDCThZn3c68asg3Wuw6MLAd5tES6BIoSMzoKcG5blPVo+sDORrMd4f2AbnZ +cMzPa43j4wNxhplty6aUKk4T1qe9BOwKFjwK6zmxxLVYo7bHViXsPlJ6qOMpFge5 +blDP+18x+B26A0piiQOuPkfyDyeR4xQghfj66Yo19V+emU3nazfvpFA+ROz6WoVm +B5x+F2pV8xeKNR7u6azDdU5YVX1TawprmxRC1+WsAYmz6qP+z8ArDITC2FMVy2fw +0IjKOtEXc/VfmtTFch5+AfGYMGMqqvJ6LcXiAhqG5TI+Dr0RtM88k+8XUBCeQ8IG +KuANaL7TiItKZYxK1MMuTJtV9IblAgMBAAGjggE7MIIBNzASBgNVHRMBAf8ECDAG +AQH/AgEAMA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUWaRmBlKge5WSPKOUByeW +dFv5PdAwHwYDVR0jBBgwFoAUhBjMhTTsvAyUlC4IWZzHshBOCggwewYIKwYBBQUH +AQEEbzBtMC8GCCsGAQUFBzABhiNodHRwOi8vb2NzcC5yb290Y2ExLmFtYXpvbnRy +dXN0LmNvbTA6BggrBgEFBQcwAoYuaHR0cDovL2NydC5yb290Y2ExLmFtYXpvbnRy +dXN0LmNvbS9yb290Y2ExLmNlcjA/BgNVHR8EODA2MDSgMqAwhi5odHRwOi8vY3Js +LnJvb3RjYTEuYW1hem9udHJ1c3QuY29tL3Jvb3RjYTEuY3JsMBMGA1UdIAQMMAow +CAYGZ4EMAQIBMA0GCSqGSIb3DQEBCwUAA4IBAQCFkr41u3nPo4FCHOTjY3NTOVI1 +59Gt/a6ZiqyJEi+752+a1U5y6iAwYfmXss2lJwJFqMp2PphKg5625kXg8kP2CN5t +6G7bMQcT8C8xDZNtYTd7WPD8UZiRKAJPBXa30/AbwuZe0GaFEQ8ugcYQgSn+IGBI +8/LwhBNTZTUVEWuCUUBVV18YtbAiPq3yXqMB48Oz+ctBWuZSkbvkNodPLamkB2g1 +upRyzQ7qDn1X8nn8N8V7YJ6y68AtkHcNSRAnpTitxBKjtKPISLMVCx7i4hncxHZS +yLyKQXhw2W2Xs0qLeC1etA+jTGDK4UfLeC0SF7FSi8o5LL21L8IzApar2pR/ +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDQTCCAimgAwIBAgITBmyfz5m/jAo54vB4ikPmljZbyjANBgkqhkiG9w0BAQsF +ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6 +b24gUm9vdCBDQSAxMB4XDTE1MDUyNjAwMDAwMFoXDTM4MDExNzAwMDAwMFowOTEL +MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJv +b3QgQ0EgMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALJ4gHHKeNXj +ca9HgFB0fW7Y14h29Jlo91ghYPl0hAEvrAIthtOgQ3pOsqTQNroBvo3bSMgHFzZM +9O6II8c+6zf1tRn4SWiw3te5djgdYZ6k/oI2peVKVuRF4fn9tBb6dNqcmzU5L/qw +IFAGbHrQgLKm+a/sRxmPUDgH3KKHOVj4utWp+UhnMJbulHheb4mjUcAwhmahRWa6 +VOujw5H5SNz/0egwLX0tdHA114gk957EWW67c4cX8jJGKLhD+rcdqsq08p8kDi1L +93FcXmn/6pUCyziKrlA4b9v7LWIbxcceVOF34GfID5yHI9Y/QCB/IIDEgEw+OyQm +jgSubJrIqg0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC +AYYwHQYDVR0OBBYEFIQYzIU07LwMlJQuCFmcx7IQTgoIMA0GCSqGSIb3DQEBCwUA +A4IBAQCY8jdaQZChGsV2USggNiMOruYou6r4lK5IpDB/G/wkjUu0yKGX9rbxenDI +U5PMCCjjmCXPI6T53iHTfIUJrU6adTrCC2qJeHZERxhlbI1Bjjt/msv0tadQ1wUs +N+gDS63pYaACbvXy8MWy7Vu33PqUXHeeE6V/Uq2V8viTO96LXFvKWlJbYK8U90vv +o/ufQJVtMVT8QtPHRh8jrdkPSHCa2XV4cdFyQzR1bldZwgJcJmApzyMZFo6IQ6XU +5MsI+yMRQ+hDKXJioaldXgjUkK642M4UwtBV8ob2xJNDd2ZhwLnoQdeXeGADbkpy +rqXRfboQnoZsG4q5WTP468SQvvG5 +-----END CERTIFICATE----- diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/mongodb-download.crt b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/mongodb-download.crt new file mode 100644 index 000000000..1fe9cdd17 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/mongodb-download.crt @@ -0,0 +1,112 @@ +-----BEGIN CERTIFICATE----- +MIIF9zCCBN+gAwIBAgIQDTiokZJgKoaLCOzt0K3ulzANBgkqhkiG9w0BAQsFADBG +MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRUwEwYDVQQLEwxTZXJ2ZXIg +Q0EgMUIxDzANBgNVBAMTBkFtYXpvbjAeFw0yMjA2MjIwMDAwMDBaFw0yMzA3MjEy +MzU5NTlaMCAxHjAcBgNVBAMTFWRvd25sb2Fkcy5tb25nb2RiLmNvbTCCASIwDQYJ +KoZIhvcNAQEBBQADggEPADCCAQoCggEBANZyoobPRnuB0Y8g1MNre1ptStvETcBw +bXh3g7QtgEoFUryEQPnrkPQJUh7vWmVAhwyfYb3NjoGPoUfCC7vc2kVqXUfydV45 +/V8mUhs/8dbpZXn//sfoFum5PrbX+GydWrvtC17USkWto+Miw046dqEntmill7J4 +znR3EHmvNZiXMkf67QCyBjpTT71LYecJFjKVxjUdC3xe8TOsCsx+2IPMVNvIeqxt +G3aW0rPxuFlQ7oIyXgdRXtlD5vdoPioxk+P2vj1gmKtTGbKUTDqac2r2cwP0uuGo +xV0gN/JGmufEu6bcngIj2DXpMEtBgHcYXk8IgQpe4QLiRdrgaK+DMz8CAwEAAaOC +AwUwggMBMB8GA1UdIwQYMBaAFFmkZgZSoHuVkjyjlAcnlnRb+T3QMB0GA1UdDgQW +BBQQ/1E/6lSuAqx97QEK8lVn0L81bjA1BgNVHREELjAsghVkb3dubG9hZHMubW9u +Z29kYi5jb22CE2Rvd25sb2Fkcy4xMGdlbi5jb20wDgYDVR0PAQH/BAQDAgWgMB0G +A1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjA9BgNVHR8ENjA0MDKgMKAuhixo +dHRwOi8vY3JsLnNjYTFiLmFtYXpvbnRydXN0LmNvbS9zY2ExYi0xLmNybDATBgNV +HSAEDDAKMAgGBmeBDAECATB1BggrBgEFBQcBAQRpMGcwLQYIKwYBBQUHMAGGIWh0 +dHA6Ly9vY3NwLnNjYTFiLmFtYXpvbnRydXN0LmNvbTA2BggrBgEFBQcwAoYqaHR0 +cDovL2NydC5zY2ExYi5hbWF6b250cnVzdC5jb20vc2NhMWIuY3J0MAwGA1UdEwEB +/wQCMAAwggF+BgorBgEEAdZ5AgQCBIIBbgSCAWoBaAB2AOg+0No+9QY1MudXKLyJ +a8kD08vREWvs62nhd31tBr1uAAABgYyc2+4AAAQDAEcwRQIgJcz+XaaaFAcPiteo +AaunoWepexB4vim6CmH5RjUxp9gCIQCD4rMXUFwiUbwrCvzYDNxoLmApMCWOwxgF +Sg0KpEFcyQB2ADXPGRu/sWxXvw+tTG1Cy7u2JyAmUeo/4SrvqAPDO9ZMAAABgYyc +3CEAAAQDAEcwRQIhAMKh1TL/yOlbSCIGf1NBMy3y+TLSjg672TAbxks+i/w/AiBX +RMleFwVvl0rfn7mtjx4sNg/KV7jxGWlSsE9HVE59tQB2ALc++yTfnE26dfI5xbpY +9Gxd/ELPep81xJ4dCYEl7bSZAAABgYyc3CQAAAQDAEcwRQIhAMcmDntNQU28qZd0 +8SaSdXKsb6DxJC/aZF+aYCi2dv14AiBHPCw6fOGgPSYM4yhs5cdqwK9wUH23G+22 +yYj5PrHTtTANBgkqhkiG9w0BAQsFAAOCAQEAooTXKMDE9j5bXGpGB8pIyYcDJVZK +Xe14ClMwJdcIr2dJM1HY++2+x9taTT1+C/bxwOfU+S5O68cGTcSY5/HZKbhIMHo4 +oGBzpfREIxMLl9n74USGBbf7h8bI+m7wJK+68HhH3La6Hd1tic6DkPmpVxrRB5ux +as1tbGN26JON/UqKxUZwcx+szhdEApWkmEJBm9S3A6/jN/yIPW5IiS084jRSk9YG +p6TInSGnHnf3M5CnlSUEG1tgMNl1tbvAf1NIsAdMSDwahTtZqO39N8XD7X/IriT2 +z20gl9DxUae6etCoGbo44+giCkqDfIeZFcabLqJiD8bNz6wj6ac17pysXQ== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIESTCCAzGgAwIBAgITBn+UV4WH6Kx33rJTMlu8mYtWDTANBgkqhkiG9w0BAQsF +ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6 +b24gUm9vdCBDQSAxMB4XDTE1MTAyMjAwMDAwMFoXDTI1MTAxOTAwMDAwMFowRjEL +MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEVMBMGA1UECxMMU2VydmVyIENB +IDFCMQ8wDQYDVQQDEwZBbWF6b24wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK +AoIBAQDCThZn3c68asg3Wuw6MLAd5tES6BIoSMzoKcG5blPVo+sDORrMd4f2AbnZ +cMzPa43j4wNxhplty6aUKk4T1qe9BOwKFjwK6zmxxLVYo7bHViXsPlJ6qOMpFge5 +blDP+18x+B26A0piiQOuPkfyDyeR4xQghfj66Yo19V+emU3nazfvpFA+ROz6WoVm +B5x+F2pV8xeKNR7u6azDdU5YVX1TawprmxRC1+WsAYmz6qP+z8ArDITC2FMVy2fw +0IjKOtEXc/VfmtTFch5+AfGYMGMqqvJ6LcXiAhqG5TI+Dr0RtM88k+8XUBCeQ8IG +KuANaL7TiItKZYxK1MMuTJtV9IblAgMBAAGjggE7MIIBNzASBgNVHRMBAf8ECDAG +AQH/AgEAMA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUWaRmBlKge5WSPKOUByeW +dFv5PdAwHwYDVR0jBBgwFoAUhBjMhTTsvAyUlC4IWZzHshBOCggwewYIKwYBBQUH +AQEEbzBtMC8GCCsGAQUFBzABhiNodHRwOi8vb2NzcC5yb290Y2ExLmFtYXpvbnRy +dXN0LmNvbTA6BggrBgEFBQcwAoYuaHR0cDovL2NydC5yb290Y2ExLmFtYXpvbnRy +dXN0LmNvbS9yb290Y2ExLmNlcjA/BgNVHR8EODA2MDSgMqAwhi5odHRwOi8vY3Js +LnJvb3RjYTEuYW1hem9udHJ1c3QuY29tL3Jvb3RjYTEuY3JsMBMGA1UdIAQMMAow +CAYGZ4EMAQIBMA0GCSqGSIb3DQEBCwUAA4IBAQCFkr41u3nPo4FCHOTjY3NTOVI1 +59Gt/a6ZiqyJEi+752+a1U5y6iAwYfmXss2lJwJFqMp2PphKg5625kXg8kP2CN5t +6G7bMQcT8C8xDZNtYTd7WPD8UZiRKAJPBXa30/AbwuZe0GaFEQ8ugcYQgSn+IGBI +8/LwhBNTZTUVEWuCUUBVV18YtbAiPq3yXqMB48Oz+ctBWuZSkbvkNodPLamkB2g1 +upRyzQ7qDn1X8nn8N8V7YJ6y68AtkHcNSRAnpTitxBKjtKPISLMVCx7i4hncxHZS +yLyKQXhw2W2Xs0qLeC1etA+jTGDK4UfLeC0SF7FSi8o5LL21L8IzApar2pR/ +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIEkjCCA3qgAwIBAgITBn+USionzfP6wq4rAfkI7rnExjANBgkqhkiG9w0BAQsF +ADCBmDELMAkGA1UEBhMCVVMxEDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNj +b3R0c2RhbGUxJTAjBgNVBAoTHFN0YXJmaWVsZCBUZWNobm9sb2dpZXMsIEluYy4x +OzA5BgNVBAMTMlN0YXJmaWVsZCBTZXJ2aWNlcyBSb290IENlcnRpZmljYXRlIEF1 +dGhvcml0eSAtIEcyMB4XDTE1MDUyNTEyMDAwMFoXDTM3MTIzMTAxMDAwMFowOTEL +MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJv +b3QgQ0EgMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALJ4gHHKeNXj +ca9HgFB0fW7Y14h29Jlo91ghYPl0hAEvrAIthtOgQ3pOsqTQNroBvo3bSMgHFzZM +9O6II8c+6zf1tRn4SWiw3te5djgdYZ6k/oI2peVKVuRF4fn9tBb6dNqcmzU5L/qw +IFAGbHrQgLKm+a/sRxmPUDgH3KKHOVj4utWp+UhnMJbulHheb4mjUcAwhmahRWa6 +VOujw5H5SNz/0egwLX0tdHA114gk957EWW67c4cX8jJGKLhD+rcdqsq08p8kDi1L +93FcXmn/6pUCyziKrlA4b9v7LWIbxcceVOF34GfID5yHI9Y/QCB/IIDEgEw+OyQm +jgSubJrIqg0CAwEAAaOCATEwggEtMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/ +BAQDAgGGMB0GA1UdDgQWBBSEGMyFNOy8DJSULghZnMeyEE4KCDAfBgNVHSMEGDAW +gBScXwDfqgHXMCs4iKK4bUqc8hGRgzB4BggrBgEFBQcBAQRsMGowLgYIKwYBBQUH +MAGGImh0dHA6Ly9vY3NwLnJvb3RnMi5hbWF6b250cnVzdC5jb20wOAYIKwYBBQUH +MAKGLGh0dHA6Ly9jcnQucm9vdGcyLmFtYXpvbnRydXN0LmNvbS9yb290ZzIuY2Vy +MD0GA1UdHwQ2MDQwMqAwoC6GLGh0dHA6Ly9jcmwucm9vdGcyLmFtYXpvbnRydXN0 +LmNvbS9yb290ZzIuY3JsMBEGA1UdIAQKMAgwBgYEVR0gADANBgkqhkiG9w0BAQsF +AAOCAQEAYjdCXLwQtT6LLOkMm2xF4gcAevnFWAu5CIw+7bMlPLVvUOTNNWqnkzSW +MiGpSESrnO09tKpzbeR/FoCJbM8oAxiDR3mjEH4wW6w7sGDgd9QIpuEdfF7Au/ma +eyKdpwAJfqxGF4PcnCZXmTA5YpaP7dreqsXMGz7KQ2hsVxa81Q4gLv7/wmpdLqBK +bRRYh5TmOTFffHPLkIhqhBGWJ6bt2YFGpn6jcgAKUj6DiAdjd4lpFw85hdKrCEVN +0FE6/V1dN2RMfjCyVSRCnTawXZwXgWHxyvkQAiSr6w10kY17RSlQOYiypok1JR4U +akcjMS9cmvqtmg5iUaQqqcT5NJ0hGA== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIEdTCCA12gAwIBAgIJAKcOSkw0grd/MA0GCSqGSIb3DQEBCwUAMGgxCzAJBgNV +BAYTAlVTMSUwIwYDVQQKExxTdGFyZmllbGQgVGVjaG5vbG9naWVzLCBJbmMuMTIw +MAYDVQQLEylTdGFyZmllbGQgQ2xhc3MgMiBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0 +eTAeFw0wOTA5MDIwMDAwMDBaFw0zNDA2MjgxNzM5MTZaMIGYMQswCQYDVQQGEwJV +UzEQMA4GA1UECBMHQXJpem9uYTETMBEGA1UEBxMKU2NvdHRzZGFsZTElMCMGA1UE +ChMcU3RhcmZpZWxkIFRlY2hub2xvZ2llcywgSW5jLjE7MDkGA1UEAxMyU3RhcmZp +ZWxkIFNlcnZpY2VzIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IC0gRzIwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDVDDrEKvlO4vW+GZdfjohTsR8/ +y8+fIBNtKTrID30892t2OGPZNmCom15cAICyL1l/9of5JUOG52kbUpqQ4XHj2C0N +Tm/2yEnZtvMaVq4rtnQU68/7JuMauh2WLmo7WJSJR1b/JaCTcFOD2oR0FMNnngRo +Ot+OQFodSk7PQ5E751bWAHDLUu57fa4657wx+UX2wmDPE1kCK4DMNEffud6QZW0C +zyyRpqbn3oUYSXxmTqM6bam17jQuug0DuDPfR+uxa40l2ZvOgdFFRjKWcIfeAg5J +Q4W2bHO7ZOphQazJ1FTfhy/HIrImzJ9ZVGif/L4qL8RVHHVAYBeFAlU5i38FAgMB +AAGjgfAwge0wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0O +BBYEFJxfAN+qAdcwKziIorhtSpzyEZGDMB8GA1UdIwQYMBaAFL9ft9HO3R+G9FtV +rNzXEMIOqYjnME8GCCsGAQUFBwEBBEMwQTAcBggrBgEFBQcwAYYQaHR0cDovL28u +c3MyLnVzLzAhBggrBgEFBQcwAoYVaHR0cDovL3guc3MyLnVzL3guY2VyMCYGA1Ud +HwQfMB0wG6AZoBeGFWh0dHA6Ly9zLnNzMi51cy9yLmNybDARBgNVHSAECjAIMAYG +BFUdIAAwDQYJKoZIhvcNAQELBQADggEBACMd44pXyn3pF3lM8R5V/cxTbj5HD9/G +VfKyBDbtgB9TxF00KGu+x1X8Z+rLP3+QsjPNG1gQggL4+C/1E2DUBc7xgQjB3ad1 +l08YuW3e95ORCLp+QCztweq7dp4zBncdDQh/U90bZKuCJ/Fp1U1ervShw3WnWEQt +8jxwmKy6abaVd38PMV4s/KCHOkdp8Hlf9BRUpJVeEXgSYCfOn8J3/yNTd126/+pZ +59vPr5KW7ySaNRB6nJHGDn2Z9j8Z3/VyVOEVqQdZe4O/Ui5GjLIAZHYcSNPYeehu +VsyuLAOQ1xk4meTKCRlb/weWsKh/NEnfVqn3sF/tM+2MR7cwA130A4w= +-----END CERTIFICATE----- diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/mongodb_versions_claim.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/mongodb_versions_claim.yaml new file mode 100644 index 000000000..f9d790ab1 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/mongodb_versions_claim.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: mongodb-versions-claim +spec: + storageClassName: gp2 + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 3Gi diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_appdb_configure_all_images.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_appdb_configure_all_images.yaml new file mode 100644 index 000000000..f45592c32 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_appdb_configure_all_images.yaml @@ -0,0 +1,18 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: om-upgrade +spec: + replicas: 1 + # version is configured in the test + adminCredentials: ops-manager-admin-secret + configuration: + mms.testUtil.enabled: "true" + + backup: + enabled: false + + applicationDatabase: + # version is configured in the test + members: 3 + version: "4.4.20-ent" diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_appdb_scale_up_down.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_appdb_scale_up_down.yaml new file mode 100644 index 000000000..b3cd1f2d9 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_appdb_scale_up_down.yaml @@ -0,0 +1,17 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: om-scale +spec: + replicas: 1 + version: 5.0.1 + adminCredentials: ops-manager-admin-secret + + applicationDatabase: + version: "4.4.20-ent" + members: 3 + podSpec: + cpu: '0.25' + + backup: + enabled: false diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_appdb_scram.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_appdb_scram.yaml new file mode 100644 index 000000000..4c3292629 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_appdb_scram.yaml @@ -0,0 +1,15 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: om-scram +spec: + replicas: 1 + version: 5.0.1 + adminCredentials: ops-manager-admin-secret + + applicationDatabase: + members: 3 + version: "4.4.20-ent" + + backup: + enabled: false diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_appdb_upgrade.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_appdb_upgrade.yaml new file mode 100644 index 000000000..58418d697 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_appdb_upgrade.yaml @@ -0,0 +1,18 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: om-upgrade +spec: + replicas: 1 + version: 4.4.0 + adminCredentials: ops-manager-admin-secret + + applicationDatabase: + members: 3 + version: 4.4.20-ent + additionalMongodConfig: + operationProfiling: + mode: slowOp + + backup: + enabled: false diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_backup_delete_sts.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_backup_delete_sts.yaml new file mode 100644 index 000000000..3b0ed92c7 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_backup_delete_sts.yaml @@ -0,0 +1,37 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: om-backup +spec: + replicas: 1 + version: 4.2.13 + adminCredentials: ops-manager-admin-secret + backup: + enabled: true + opLogStores: + - name: oplog1 + mongodbResourceRef: + name: my-mongodb-oplog + blockStores: + - name: blockStore1 + mongodbResourceRef: + name: my-mongodb-blockstore + + applicationDatabase: + members: 3 + version: 4.4.20-ent + + # Dev: adding this just to avoid wizard when opening OM UI + # (note, that to debug issues in OM you need to add 'spec.externalConnectivity.type=NodePort' + # and specify some port: 'port: 32400'. Don't forget to open it in AWS) + configuration: + automation.versions.source: mongodb + mms.adminEmailAddr: cloud-manager-support@mongodb.com + mms.fromEmailAddr: cloud-manager-support@mongodb.com + mms.ignoreInitialUiSetup: "true" + mms.mail.hostname: email-smtp.us-east-1.amazonaws.com + mms.mail.port: "465" + mms.mail.ssl: "true" + mms.mail.transport: smtp + mms.minimumTLSVersion: TLSv1.2 + mms.replyToEmailAddr: cloud-manager-support@mongodb.com diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_https_enabled.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_https_enabled.yaml new file mode 100644 index 000000000..d2b536e3b --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_https_enabled.yaml @@ -0,0 +1,139 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: om-with-https + +spec: + replicas: 1 + version: 4.2.15 + adminCredentials: ops-manager-admin-secret + + configuration: + mms.testUtil.enabled: "true" + automation.versions.source: local + + applicationDatabase: + members: 3 + version: "4.4.20-ent" + + statefulSet: + spec: + template: + spec: + volumes: + - name: mongodb-versions + emptyDir: {} + containers: + - name: mongodb-ops-manager + volumeMounts: + - name: mongodb-versions + mountPath: /mongodb-ops-manager/mongodb-releases + + # The initContainers will download the require 4.2+ and 4.4+ versions of MongoDB + # allowing Ops Manager to act as download endpoint for the automation agent + # for this particular version. + # This is required because of public Internet downloads not being + # possible after using a custom-ca for the OM HTTPS server. + initContainers: + - name: setting-up-rhel-mongodb + image: curlimages/curl:latest + command: + - curl + - -L + - https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-rhel80-4.2.8.tgz + - -o + - /mongodb-ops-manager/mongodb-releases/mongodb-linux-x86_64-rhel80-4.2.8.tgz + volumeMounts: + - name: mongodb-versions + mountPath: /mongodb-ops-manager/mongodb-releases + - name: setting-up-ubuntu-mongodb-ubuntu1604 + image: curlimages/curl:latest + command: + - curl + - -L + - https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-ubuntu1604-4.2.8.tgz + - -o + - /mongodb-ops-manager/mongodb-releases/mongodb-linux-x86_64-ubuntu1604-4.2.8.tgz + volumeMounts: + - name: mongodb-versions + mountPath: /mongodb-ops-manager/mongodb-releases + - name: setting-up-rhel-mongodb-4-4 + image: curlimages/curl:latest + command: + - curl + - -L + - https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-rhel80-4.4.11.tgz + - -o + - /mongodb-ops-manager/mongodb-releases/mongodb-linux-x86_64-rhel80-4.4.11.tgz + volumeMounts: + - name: mongodb-versions + mountPath: /mongodb-ops-manager/mongodb-releases + - name: setting-up-ubuntu-mongodb-4-4-ubuntu1604 + image: curlimages/curl:latest + command: + - curl + - -L + - https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-ubuntu1604-4.4.11.tgz + - -o + - /mongodb-ops-manager/mongodb-releases/mongodb-linux-x86_64-ubuntu1604-4.4.11.tgz + volumeMounts: + - name: mongodb-versions + mountPath: /mongodb-ops-manager/mongodb-releases + - name: setting-up-ubuntu-mongodb-4-4-ubuntu1804 + image: curlimages/curl:latest + command: + - curl + - -L + - https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-ubuntu1804-4.4.11.tgz + - -o + - /mongodb-ops-manager/mongodb-releases/mongodb-linux-x86_64-ubuntu1804-4.4.11.tgz + volumeMounts: + - name: mongodb-versions + mountPath: /mongodb-ops-manager/mongodb-releases + - name: setting-up-rhel-mongodb-5-0 + image: curlimages/curl:latest + command: + - curl + - -L + - https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-rhel80-5.0.5.tgz + - -o + - /mongodb-ops-manager/mongodb-releases/mongodb-linux-x86_64-rhel80-5.0.5.tgz + volumeMounts: + - name: mongodb-versions + mountPath: /mongodb-ops-manager/mongodb-releases + - name: setting-up-rhel-mongodb-6-0 + image: curlimages/curl:latest + command: + - curl + - -L + - https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-rhel80-6.0.5.tgz + - -o + - /mongodb-ops-manager/mongodb-releases/mongodb-linux-x86_64-rhel80-6.0.5.tgz + volumeMounts: + - name: mongodb-versions + mountPath: /mongodb-ops-manager/mongodb-releases + - name: setting-up-ubuntu1806-mongodb-5-0 + image: curlimages/curl:latest + command: + - curl + - -L + - https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-ubuntu1804-5.0.5.tgz + - -o + - /mongodb-ops-manager/mongodb-releases/mongodb-linux-x86_64-ubuntu1804-5.0.5.tgz + volumeMounts: + - name: mongodb-versions + mountPath: /mongodb-ops-manager/mongodb-releases + - name: setting-up-ubuntu1806-mongodb-6-0 + image: curlimages/curl:latest + command: + - curl + - -L + - https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-ubuntu1804-6.0.5.tgz + - -o + - /mongodb-ops-manager/mongodb-releases/mongodb-linux-x86_64-ubuntu1804-6.0.5.tgz + volumeMounts: + - name: mongodb-versions + mountPath: /mongodb-ops-manager/mongodb-releases + + backup: + enabled: false diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_localmode-multiple-pv.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_localmode-multiple-pv.yaml new file mode 100644 index 000000000..b7291fb52 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_localmode-multiple-pv.yaml @@ -0,0 +1,38 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: om-localmode-multiple-pv +spec: + replicas: 2 + version: 4.2.12 + adminCredentials: ops-manager-admin-secret + configuration: + automation.versions.source: local + + statefulSet: + spec: + volumeClaimTemplates: + - metadata: + name: mongodb-versions + spec: + accessModes: [ "ReadWriteOnce" ] + resources: + requests: + storage: 20G + template: + spec: + containers: + - name: mongodb-ops-manager + volumeMounts: + - name: mongodb-versions + mountPath: /mongodb-ops-manager/mongodb-releases + + + backup: + enabled: false + + applicationDatabase: + version: "4.4.20-ent" + members: 3 + + diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_localmode-single-pv.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_localmode-single-pv.yaml new file mode 100644 index 000000000..2fbfbc48f --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_localmode-single-pv.yaml @@ -0,0 +1,66 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: om-localmode-single-pv +spec: + replicas: 1 + version: 4.4.1 + adminCredentials: ops-manager-admin-secret + configuration: + mms.testUtil.enabled: "true" + automation.versions.source: local + + statefulSet: + spec: + template: + spec: + volumes: + - name: mongodb-versions + persistentVolumeClaim: + claimName: mongodb-versions-claim + containers: + - name: mongodb-ops-manager + volumeMounts: + - name: mongodb-versions + mountPath: /mongodb-ops-manager/mongodb-releases + initContainers: + - name: mongod-binaries-ubuntu1604-init-container + image: quay.io/mongodb/mongodb-enterprise-init-mongod-ubuntu1604:4.2.8 + imagePullPolicy: Always + command: + - cp + - -a + - /binaries/. + - /mongodb-ops-manager/mongodb-releases/ + volumeMounts: + - name: mongodb-versions + mountPath: /mongodb-ops-manager/mongodb-releases + - name: mongod-binaries-ubuntu1804-init-container + image: quay.io/mongodb/mongodb-enterprise-init-mongod-ubuntu1804:4.2.8 + imagePullPolicy: Always + command: + - cp + - -a + - /binaries/. + - /mongodb-ops-manager/mongodb-releases/ + volumeMounts: + - name: mongodb-versions + mountPath: /mongodb-ops-manager/mongodb-releases + - name: mongod-binaries-ubi7-init-container + image: quay.io/mongodb/mongodb-enterprise-init-mongod-rhel80:4.2.8 + imagePullPolicy: Always + command: + - cp + - -a + - /binaries/. + - /mongodb-ops-manager/mongodb-releases/ + volumeMounts: + - name: mongodb-versions + mountPath: /mongodb-ops-manager/mongodb-releases + + backup: + enabled: false + + applicationDatabase: + version: "4.4.20-ent" + members: 3 diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_appdb_monitoring_tls.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_appdb_monitoring_tls.yaml new file mode 100644 index 000000000..e4ec802f4 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_appdb_monitoring_tls.yaml @@ -0,0 +1,40 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: om-tls-monitored-appdb +spec: + replicas: 1 + version: 4.4.1 + adminCredentials: ops-manager-admin-secret + security: + tls: + secretRef: + name: certs-for-ops-manager + ca: issuer-ca + backup: + enabled: false + applicationDatabase: + members: 3 + version: 5.0.14-ent + + passwordSecretKeyRef: + name: appdb-secret + + security: + tls: + ca: issuer-ca + secretRef: + prefix: appdb + + # adding this just to avoid wizard when opening OM UI + configuration: + automation.versions.source: mongodb + mms.adminEmailAddr: cloud-manager-support@mongodb.com + mms.fromEmailAddr: cloud-manager-support@mongodb.com + mms.ignoreInitialUiSetup: "true" + mms.mail.hostname: email-smtp.us-east-1.amazonaws.com + mms.mail.port: "465" + mms.mail.ssl: "true" + mms.mail.transport: smtp + mms.minimumTLSVersion: TLSv1.2 + mms.replyToEmailAddr: cloud-manager-support@mongodb.com diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_appdb_upgrade_tls.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_appdb_upgrade_tls.yaml new file mode 100644 index 000000000..d6320d900 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_appdb_upgrade_tls.yaml @@ -0,0 +1,33 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: om-appdb-upgrade-tls +spec: + replicas: 1 + version: 5.0.2 + adminCredentials: ops-manager-admin-secret + backup: + enabled: false + security: + tls: + ca: issuer-ca + applicationDatabase: + members: 3 + version: 5.0.14-ent + security: + tls: + ca: issuer-ca + + # adding this just to avoid wizard when opening OM UI + configuration: + automation.versions.source: mongodb + mms.adminEmailAddr: cloud-manager-support@mongodb.com + mms.fromEmailAddr: cloud-manager-support@mongodb.com + mms.ignoreInitialUiSetup: "true" + mms.mail.hostname: email-smtp.us-east-1.amazonaws.com + mms.mail.port: "465" + mms.mail.ssl: "true" + mms.mail.transport: smtp + mms.minimumTLSVersion: TLSv1.2 + mms.replyToEmailAddr: cloud-manager-support@mongodb.com + diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_backup.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_backup.yaml new file mode 100644 index 000000000..432d529bc --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_backup.yaml @@ -0,0 +1,49 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: om-backup +spec: + replicas: 1 + version: 5.0.17 + adminCredentials: ops-manager-admin-secret + backup: + enabled: true + headDB: + storage: 500M + opLogStores: + - name: oplog1 + mongodbResourceRef: + name: my-mongodb-oplog + blockStores: + - name: blockStore1 + mongodbResourceRef: + name: my-mongodb-blockstore + s3Stores: + - name: s3Store1 + mongodbResourceRef: + name: my-mongodb-s3 + s3SecretRef: + name: my-s3-secret + pathStyleAccessEnabled: true + s3BucketEndpoint: s3.us-east-1.amazonaws.com + s3BucketName: test-bucket + + applicationDatabase: + members: 3 + version: 4.4.20-ent + + # Dev: adding this just to avoid wizard when opening OM UI + # (note, that to debug issues in OM you need to add 'spec.externalConnectivity.type=NodePort' + # and specify some port: 'port: 32400'. Don't forget to open it in AWS) + configuration: + automation.versions.source: mongodb + mms.adminEmailAddr: cloud-manager-support@mongodb.com + mms.fromEmailAddr: cloud-manager-support@mongodb.com + mms.ignoreInitialUiSetup: "true" + mms.mail.hostname: email-smtp.us-east-1.amazonaws.com + mms.mail.port: "465" + mms.mail.ssl: "true" + mms.mail.transport: smtp + mms.minimumTLSVersion: TLSv1.2 + mms.replyToEmailAddr: cloud-manager-support@mongodb.com + diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_backup_irsa.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_backup_irsa.yaml new file mode 100644 index 000000000..1a817e2ed --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_backup_irsa.yaml @@ -0,0 +1,46 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: om-backup +spec: + replicas: 1 + version: 5.0.4 + adminCredentials: ops-manager-admin-secret + backup: + enabled: true + headDB: + storage: 500M + opLogStores: + - name: oplog1 + mongodbResourceRef: + name: my-mongodb-oplog + s3Stores: + - name: s3Store1 + mongodbResourceRef: + name: my-mongodb-s3 + s3SecretRef: + name: my-s3-secret + pathStyleAccessEnabled: true + s3BucketEndpoint: s3.us-east-1.amazonaws.com + s3BucketName: test-bucket + + applicationDatabase: + members: 3 + version: 4.4.20-ent + + # Dev: adding this just to avoid wizard when opening OM UI + # (note, that to debug issues in OM you need to add 'spec.externalConnectivity.type=NodePort' + # and specify some port: 'port: 32400'. Don't forget to open it in AWS) + configuration: + automation.versions.source: mongodb + mms.adminEmailAddr: cloud-manager-support@mongodb.com + mms.fromEmailAddr: cloud-manager-support@mongodb.com + mms.ignoreInitialUiSetup: "true" + mms.mail.hostname: email-smtp.us-east-1.amazonaws.com + mms.mail.port: "465" + mms.mail.ssl: "true" + mms.mail.transport: smtp + mms.minimumTLSVersion: TLSv1.2 + mms.replyToEmailAddr: cloud-manager-support@mongodb.com + brs.store.s3.iam.flavor: web-identity-token + diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_backup_kmip.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_backup_kmip.yaml new file mode 100644 index 000000000..67c59394f --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_backup_kmip.yaml @@ -0,0 +1,49 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: om-backup-kmip +spec: + replicas: 1 + version: 4.2.13 + adminCredentials: ops-manager-admin-secret + backup: + enabled: true + encryption: + kmip: + server: + url: "kmip-svc:5696" + ca: "" + s3OpLogStores: + - name: s3Store2 + s3SecretRef: + name: my-s3-secret-oplog + s3BucketEndpoint: s3.us-east-1.amazonaws.com + s3BucketName: "" + pathStyleAccessEnabled: true + s3Stores: + - name: s3Store1 + s3SecretRef: + name: my-s3-secret + pathStyleAccessEnabled: true + s3BucketEndpoint: s3.us-east-1.amazonaws.com + s3BucketName: test-bucket + applicationDatabase: + members: 3 + version: 4.4.20-ent + + # Dev: adding this just to avoid wizard when opening OM UI + # (note, that to debug issues in OM you need to add 'spec.externalConnectivity.type=NodePort' + # and specify some port: 'port: 32400'. Don't forget to open it in AWS) + configuration: + automation.versions.source: mongodb + mms.adminEmailAddr: cloud-manager-support@mongodb.com + mms.fromEmailAddr: cloud-manager-support@mongodb.com + mms.ignoreInitialUiSetup: "true" + mms.mail.hostname: email-smtp.us-east-1.amazonaws.com + mms.mail.port: "465" + mms.mail.ssl: "true" + mms.mail.transport: smtp + mms.minimumTLSVersion: TLSv1.2 + mms.replyToEmailAddr: cloud-manager-support@mongodb.com + mms.preflight.run: "false" + diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_backup_light.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_backup_light.yaml new file mode 100644 index 000000000..641762486 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_backup_light.yaml @@ -0,0 +1,43 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: om-backup + labels: + label1: val1 + label2: val2 +spec: + replicas: 1 + version: 4.2.13 + adminCredentials: ops-manager-admin-secret + backup: + enabled: true + s3Stores: + - name: s3Store1 + s3SecretRef: + name: my-s3-secret + pathStyleAccessEnabled: true + s3BucketEndpoint: s3.us-east-1.amazonaws.com + s3BucketName: test-bucket + + applicationDatabase: + members: 3 + + version: 4.4.20-ent + + # Dev: adding this just to avoid wizard when opening OM UI + # (note, that to debug issues in OM you need to add 'spec.externalConnectivity.type=NodePort' + # and specify some port: 'port: 32400'. Don't forget to open it in AWS) + configuration: + # this property is critical to make backup S3 work in OM 4.2.10 and 4.2.12 + brs.s3.validation.testing: disabled + + automation.versions.source: mongodb + mms.adminEmailAddr: cloud-manager-support@mongodb.com + mms.fromEmailAddr: cloud-manager-support@mongodb.com + mms.ignoreInitialUiSetup: "true" + mms.mail.hostname: email-smtp.us-east-1.amazonaws.com + mms.mail.port: "465" + mms.mail.ssl: "true" + mms.mail.transport: smtp + mms.minimumTLSVersion: TLSv1.2 + mms.replyToEmailAddr: cloud-manager-support@mongodb.com diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_backup_tls.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_backup_tls.yaml new file mode 100644 index 000000000..11674efbe --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_backup_tls.yaml @@ -0,0 +1,43 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: om-backup-tls +spec: + replicas: 1 + version: 4.2.10 + adminCredentials: ops-manager-admin-secret + backup: + enabled: true + opLogStores: + - name: oplog1 + mongodbResourceRef: + name: my-mongodb-oplog + blockStores: + - name: blockStore1 + mongodbResourceRef: + name: my-mongodb-blockstore + security: + tls: + ca: issuer-ca + applicationDatabase: + version: 4.4.20-ent + members: 3 + security: + tls: + ca: issuer-ca + secretRef: + prefix: appdb + + # adding this just to avoid wizard when opening OM UI + configuration: + automation.versions.source: mongodb + mms.adminEmailAddr: cloud-manager-support@mongodb.com + mms.fromEmailAddr: cloud-manager-support@mongodb.com + mms.ignoreInitialUiSetup: "true" + mms.mail.hostname: email-smtp.us-east-1.amazonaws.com + mms.mail.port: "465" + mms.mail.ssl: "true" + mms.mail.transport: smtp + mms.minimumTLSVersion: TLSv1.2 + mms.replyToEmailAddr: cloud-manager-support@mongodb.com + diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_backup_tls_s3.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_backup_tls_s3.yaml new file mode 100644 index 000000000..22e912b51 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_backup_tls_s3.yaml @@ -0,0 +1,49 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: om-backup-tls-s3 +spec: + replicas: 1 + version: 5.0.20 + adminCredentials: ops-manager-admin-secret + backup: + enabled: true + s3Stores: + - name: my-s3-block-store + s3SecretRef: + name: "" + pathStyleAccessEnabled: true + s3BucketEndpoint: "" + s3BucketName: "" + s3OpLogStores: + - name: my-s3-oplog-store + s3SecretRef: + name: "" + s3BucketEndpoint: "" + s3BucketName: "" + pathStyleAccessEnabled: true + + security: + tls: + ca: issuer-ca + applicationDatabase: + version: 4.4.20-ent + members: 3 + security: + tls: + ca: issuer-ca + secretRef: + prefix: appdb + + # adding this just to avoid wizard when opening OM UI + configuration: + automation.versions.source: mongodb + mms.adminEmailAddr: cloud-manager-support@mongodb.com + mms.fromEmailAddr: cloud-manager-support@mongodb.com + mms.ignoreInitialUiSetup: "true" + mms.mail.hostname: email-smtp.us-east-1.amazonaws.com + mms.mail.port: "465" + mms.mail.ssl: "true" + mms.mail.transport: smtp + mms.minimumTLSVersion: TLSv1.2 + mms.replyToEmailAddr: cloud-manager-support@mongodb.com diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_basic.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_basic.yaml new file mode 100644 index 000000000..f0622ad4b --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_basic.yaml @@ -0,0 +1,15 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: om-basic +spec: + replicas: 1 + version: 5.0.1 + adminCredentials: ops-manager-admin-secret + + applicationDatabase: + members: 3 + version: 4.4.20-ent + + backup: + enabled: false diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_full.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_full.yaml new file mode 100644 index 000000000..715645ac1 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_full.yaml @@ -0,0 +1,32 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: om-full +spec: + replicas: 1 + version: 5.0.1 + adminCredentials: ops-manager-admin-secret + configuration: + mms.testUtil.enabled: "true" + backup: + enabled: true + headDB: + storage: 500M + opLogStores: + - name: oplog1 + mongodbResourceRef: + name: my-mongodb-oplog + s3Stores: + - name: s3Store1 + mongodbResourceRef: + name: my-mongodb-s3 + s3SecretRef: + name: my-s3-secret + pathStyleAccessEnabled: true + s3BucketEndpoint: s3.us-east-1.amazonaws.com + s3BucketName: test-bucket + + applicationDatabase: + members: 3 + version: 5.0.14-ent + diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_jvm_params.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_jvm_params.yaml new file mode 100644 index 000000000..edfe47108 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_jvm_params.yaml @@ -0,0 +1,28 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: om-jvm-params +spec: + replicas: 1 + version: 5.0.1 + adminCredentials: ops-manager-admin-secret + + statefulSet: + spec: + template: + spec: + containers: + - name: mongodb-ops-manager + resources: + requests: + memory: "400M" + limits: + memory: "5G" + + applicationDatabase: + members: 3 + version: 4.4.20-ent + + backup: + enabled: true + jvmParameters: ["-Xmx4352m","-Xms4352m"] diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_pod_spec.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_pod_spec.yaml new file mode 100644 index 000000000..2d1e934d9 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_pod_spec.yaml @@ -0,0 +1,113 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: om-pod-spec +spec: + replicas: 1 + version: 5.0.1 + adminCredentials: ops-manager-admin-secret + configuration: + mms.testUtil.enabled: "true" + backup: + enabled: true + statefulSet: + spec: + template: + spec: + hostAliases: + - ip: "1.2.3.4" + hostnames: ["hostname"] + containers: + - name: "mongodb-backup-daemon" + resources: + requests: + cpu: '0.50' + memory: '4500M' + + statefulSet: + spec: + template: + metadata: + annotations: + key1: value1 + spec: + volumes: + - name: test-volume + emptyDir: {} + tolerations: + - key: "key" + operator: "Exists" + effect: "NoSchedule" + containers: + - name: mongodb-ops-manager + securityContext: + allowPrivilegeEscalation: false + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + readinessProbe: + failureThreshold: 20 + startupProbe: + periodSeconds: 25 + volumeMounts: + - mountPath: /somewhere + name: test-volume + resources: + limits: + cpu: '0.70' + memory: '6G' + initContainers: + - name: mongodb-enterprise-init-ops-manager + securityContext: + allowPrivilegeEscalation: false + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + + applicationDatabase: + members: 3 + version: 4.4.20-ent + podSpec: + persistence: + single: + storage: 1G + podTemplate: + spec: + # TO kill sidecar right away + terminationGracePeriodSeconds: 3 + # This container will be added to each pod as a sidecar + containers: + - name: appdb-sidecar + image: busybox + command: ["sleep"] + args: [ "infinity" ] + resources: + limits: + cpu: "1" + requests: + cpu: 500m + - name: mongod + securityContext: + allowPrivilegeEscalation: false + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + - name: mongodb-agent-monitoring + securityContext: + allowPrivilegeEscalation: false + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + - name: mongodb-agent + securityContext: + allowPrivilegeEscalation: false + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + resources: + requests: + memory: 200M + limits: + cpu: '0.25' + memory: 350M + diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_scale.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_scale.yaml new file mode 100644 index 000000000..7bc49e96f --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_scale.yaml @@ -0,0 +1,26 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: om-scale +spec: + replicas: 2 + version: 5.0.1 + adminCredentials: ops-manager-admin-secret + configuration: + mms.testUtil.enabled: "true" + automation.versions.source: mongodb + mms.adminEmailAddr: cloud-manager-support@mongodb.com + mms.fromEmailAddr: cloud-manager-support@mongodb.com + mms.ignoreInitialUiSetup: "true" + mms.mail.hostname: email-smtp.us-east-1.amazonaws.com + mms.mail.port: "465" + mms.mail.ssl: "true" + mms.mail.transport: smtp + mms.minimumTLSVersion: TLSv1.2 + mms.replyToEmailAddr: cloud-manager-support@mongodb.com + mms.monitoring.rrd.maintenanceEnabled: "false" + mms.monitoring.accessLogs.maintenanceEnabled: "false" + mms.monitoring.slowlogs.maintenanceEnabled: "false" + applicationDatabase: + members: 3 + version: 4.4.20-ent diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_secure_config.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_secure_config.yaml new file mode 100644 index 000000000..296912e0b --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_secure_config.yaml @@ -0,0 +1,35 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: om-secure-config +spec: + replicas: 1 + version: 4.2.10 + adminCredentials: ops-manager-admin-secret + backup: + enabled: false # enabled during test + opLogStores: + - name: oplog1 + mongodbResourceRef: + name: my-mongodb-oplog + blockStores: + - name: blockStore1 + mongodbResourceRef: + name: my-mongodb-blockstore + applicationDatabase: + members: 3 + version: 4.4.20-ent + + # adding this just to avoid wizard when opening OM UI + configuration: + automation.versions.source: mongodb + mms.adminEmailAddr: cloud-manager-support@mongodb.com + mms.fromEmailAddr: cloud-manager-support@mongodb.com + mms.ignoreInitialUiSetup: "true" + mms.mail.hostname: email-smtp.us-east-1.amazonaws.com + mms.mail.port: "465" + mms.mail.ssl: "true" + mms.mail.transport: smtp + mms.minimumTLSVersion: TLSv1.2 + mms.replyToEmailAddr: cloud-manager-support@mongodb.com + diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_upgrade.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_upgrade.yaml new file mode 100644 index 000000000..adbeb7e9e --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_ops_manager_upgrade.yaml @@ -0,0 +1,37 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: om-upgrade +spec: + replicas: 1 + # version is configured in the test + adminCredentials: ops-manager-admin-secret + + backup: + enabled: true + s3Stores: + - name: s3Store1 + s3SecretRef: + name: my-s3-secret + pathStyleAccessEnabled: true + s3BucketEndpoint: s3.us-east-1.amazonaws.com + s3BucketName: test-bucket + + applicationDatabase: + # version is configured in the test + members: 3 + version: "4.4.20-ent" + + # avoid wizard when opening OM UI + configuration: + automation.versions.source: mongodb + mms.testUtil.enabled: "true" + mms.adminEmailAddr: cloud-manager-support@mongodb.com + mms.fromEmailAddr: cloud-manager-support@mongodb.com + mms.ignoreInitialUiSetup: "true" + mms.mail.hostname: email-smtp.us-east-1.amazonaws.com + mms.mail.port: "465" + mms.mail.ssl: "true" + mms.mail.transport: smtp + mms.minimumTLSVersion: TLSv1.2 + mms.replyToEmailAddr: cloud-manager-support@mongodb.com diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_s3store_validation.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_s3store_validation.yaml new file mode 100644 index 000000000..1b4678618 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_s3store_validation.yaml @@ -0,0 +1,20 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: om-s3-validate +spec: + replicas: 1 + version: 5.0.1 + adminCredentials: ops-manager-admin-secret + + applicationDatabase: + members: 3 + version: 4.2.0 + backup: + enabled: true + opLogStores: + - name: "oplog-store-1" + mongodbResourceRef: + name: "my-oplog-mdb" + s3Stores: [] + diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_validation.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_validation.yaml new file mode 100644 index 000000000..507f001e6 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/om_validation.yaml @@ -0,0 +1,15 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: om-validate +spec: + replicas: 1 + version: 5.0.0 + adminCredentials: ops-manager-admin-secret + + backup: + enabled: false + + applicationDatabase: + members: 3 + version: 4.4.20-ent diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/remote_fixtures/nginx-config.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/remote_fixtures/nginx-config.yaml new file mode 100644 index 000000000..b3b37bd75 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/remote_fixtures/nginx-config.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: nginx-conf +data: + nginx.conf: | + events {} + http { + server { + server_name localhost; + listen 80; + location /linux/ { + alias /mongodb-ops-manager/mongodb-releases/linux/; + } + location /compass/ { + alias /mongodb-ops-manager/mongodb-releases/compass/; + } + } + } diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/remote_fixtures/nginx-svc.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/remote_fixtures/nginx-svc.yaml new file mode 100644 index 000000000..b4b042afe --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/remote_fixtures/nginx-svc.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: nginx-svc + labels: + app: nginx +spec: + ports: + - port: 80 + protocol: TCP + selector: + app: nginx diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/remote_fixtures/nginx.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/remote_fixtures/nginx.yaml new file mode 100644 index 000000000..a5c601083 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/remote_fixtures/nginx.yaml @@ -0,0 +1,68 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + replicas: 1 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - image: nginx:1.14.2 + imagePullPolicy: IfNotPresent + name: nginx + ports: + - containerPort: 80 + volumeMounts: + - mountPath: /mongodb-ops-manager/mongodb-releases/linux + name: mongodb-versions + - mountPath: /mongodb-ops-manager/mongodb-releases/compass + name: mongosh-versions + - name: nginx-conf + mountPath: /etc/nginx/nginx.conf + subPath: nginx.conf + initContainers: + - name: setting-up-mongosh-1-4-1 + image: curlimages/curl:latest + command: + - sh + - -c + - curl -LO https://downloads.mongodb.com/compass/mongosh-1.4.1-linux-x64.tgz --output-dir /mongodb-ops-manager/mongodb-releases/compass && true + volumeMounts: + - name: mongosh-versions + mountPath: /mongodb-ops-manager/mongodb-releases/compass + - name: setting-up-mongosh-1-6-0 + image: curlimages/curl:latest + command: + - sh + - -c + - curl -LO https://downloads.mongodb.com/compass/mongosh-1.6.0-linux-x64.tgz --output-dir /mongodb-ops-manager/mongodb-releases/compass && true + volumeMounts: + - name: mongosh-versions + mountPath: /mongodb-ops-manager/mongodb-releases/compass + - name: setting-up-mongosh-1-6-2 + image: curlimages/curl:latest + command: + - sh + - -c + - curl -LO https://downloads.mongodb.com/compass/mongosh-1.6.2-linux-x64.tgz --output-dir /mongodb-ops-manager/mongodb-releases/compass && true + volumeMounts: + - name: mongosh-versions + mountPath: /mongodb-ops-manager/mongodb-releases/compass + + restartPolicy: Always + securityContext: {} + terminationGracePeriodSeconds: 30 + volumes: + - name: mongodb-versions + emptyDir: {} + - name: mongosh-versions + emptyDir: {} + - configMap: + name: nginx-conf + name: nginx-conf diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/remote_fixtures/om_remotemode.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/remote_fixtures/om_remotemode.yaml new file mode 100644 index 000000000..ddf83a136 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/remote_fixtures/om_remotemode.yaml @@ -0,0 +1,27 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: om-remotemode +spec: + replicas: 1 + version: 4.4.0-rc2 # OM version with "remote" mode support + adminCredentials: ops-manager-admin-secret + configuration: + # CLOUDP-83792: as of OM 4.4.9 this property is critical to make remote mode work with enterprise build + automation.versions.download.baseUrl.allowOnlyAvailableBuilds: "false" + automation.versions.source: mongodb + mms.adminEmailAddr: cloud-manager-support@mongodb.com + mms.fromEmailAddr: cloud-manager-support@mongodb.com + mms.ignoreInitialUiSetup: "true" + mms.mail.hostname: email-smtp.us-east-1.amazonaws.com + mms.mail.port: "465" + mms.mail.ssl: "true" + mms.mail.transport: smtp + mms.minimumTLSVersion: TLSv1.2 + mms.replyToEmailAddr: cloud-manager-support@mongodb.com + backup: + enabled: false + + applicationDatabase: + members: 3 + version: "4.4.20-ent" diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/replica-set-for-om.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/replica-set-for-om.yaml new file mode 100644 index 000000000..fe27611e5 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/replica-set-for-om.yaml @@ -0,0 +1,16 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: the-replica-set +spec: + members: 3 + version: 4.4.11 + type: ReplicaSet + opsManager: + configMapRef: + name: om-rs-configmap + credentials: my-credentials + persistent: true + logLevel: DEBUG + diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/replica-set-kmip.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/replica-set-kmip.yaml new file mode 100644 index 000000000..26c1df31b --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/replica-set-kmip.yaml @@ -0,0 +1,54 @@ +--- +# This fixture contains KMIP OpsManager settings delivered via https://jira.mongodb.org/browse/CLOUDP-135429 +# and cluster encryption settings described in https://kb.corp.mongodb.com/article/000020599/ +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: mdb-latest +spec: + members: 3 + version: 4.4.11 + type: ReplicaSet + opsManager: + configMapRef: + name: om-rs-configmap + credentials: my-credentials + persistent: false + logLevel: DEBUG + backup: + encryption: + kmip: + client: + clientCertificatePrefix: "test-prefix" + additionalMongodConfig: + systemLog: + component: + network: + verbosity: 5 + security: + enableEncryption: true + kmip: + clientCertificateFile: "/kmip/cert/tls.crt" + serverCAFile: "/kmip/ca/ca.pem" + serverName: kmip-svc + port: 5696 + podSpec: + podTemplate: + spec: + containers: + - name: mongodb-enterprise-database + volumeMounts: + - name: mongodb-kmip-client-pem + mountPath: /kmip/cert + - name: mongodb-kmip-certificate-authority-pem + mountPath: /kmip/ca + volumes: + - name: mongodb-kmip-client-pem + secret: + secretName: test-prefix-mdb-latest-kmip-client + - name: mongodb-kmip-certificate-authority-pem + configMap: + name: issuer-ca + items: + - key: ca-pem + path: ca.pem diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/scram-sha-user-backing-db.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/scram-sha-user-backing-db.yaml new file mode 100644 index 000000000..c4103400a --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/scram-sha-user-backing-db.yaml @@ -0,0 +1,20 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDBUser +metadata: + name: mms-user-1 +spec: + passwordSecretKeyRef: + name: mms-user-1-password + key: password + username: "mms-user-1@/" + db: "admin" + mongodbResourceRef: + name: "my-replica-set" + roles: + - db: "admin" + name: "clusterMonitor" + - db: "admin" + name: "readWriteAnyDatabase" + - db: "admin" + name: "dbAdminAnyDatabase" diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/sharded-cluster-for-om.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/sharded-cluster-for-om.yaml new file mode 100644 index 000000000..496ad4218 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/sharded-cluster-for-om.yaml @@ -0,0 +1,19 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: the-sharded-cluster +spec: + shardCount: 2 + mongodsPerShardCount: 3 + mongosCount: 1 + configServerCount: 1 + version: 4.2.8 + type: ShardedCluster + opsManager: + configMapRef: + name: om-sc-configmap + credentials: my-credentials + persistent: false + logLevel: DEBUG + diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/upgrade_appdb.yaml b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/upgrade_appdb.yaml new file mode 100644 index 000000000..3646f3398 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/fixtures/upgrade_appdb.yaml @@ -0,0 +1,16 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: om-full +spec: + replicas: 1 + version: 5.0.1 + adminCredentials: ops-manager-admin-secret + configuration: + mms.testUtil.enabled: "true" + backup: + enabled: false + applicationDatabase: + members: 3 + version: 4.4.20-ent + diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_appdb_multi_change.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_appdb_multi_change.py new file mode 100644 index 000000000..93a4c891f --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_appdb_multi_change.py @@ -0,0 +1,45 @@ +from kubetester import find_fixture +from kubetester.mongodb import Phase +from kubetester.opsmanager import MongoDBOpsManager +from pytest import mark, fixture + + +@fixture(scope="module") +def ops_manager( + namespace: str, custom_version: str, custom_appdb_version: str +) -> MongoDBOpsManager: + resource = MongoDBOpsManager.from_yaml( + find_fixture("om_ops_manager_basic.yaml"), namespace=namespace + ) + + resource.set_version(custom_version) + resource.set_appdb_version(custom_appdb_version) + return resource.create() + + +@mark.e2e_om_appdb_multi_change +def test_appdb(ops_manager: MongoDBOpsManager): + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=400) + + +@mark.e2e_om_appdb_multi_change +def test_change_appdb(ops_manager: MongoDBOpsManager): + """This change affects both the StatefulSet spec (agent flags) and the AutomationConfig (mongod config). + Appdb controller is expected to perform wait after the automation config push so that all the pods got to not ready + status and the next StatefulSet spec change didn't result in the immediate rolling upgrade. + See CLOUDP-73296 for more details.""" + ops_manager.load() + ops_manager["spec"]["applicationDatabase"]["agent"] = { + "startupOptions": {"maxLogFiles": "30"} + } + ops_manager["spec"]["applicationDatabase"]["additionalMongodConfig"] = { + "operationProfiling": {"mode": "slowOp"} + } + ops_manager.update() + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=600) + + +@mark.e2e_om_appdb_multi_change +def test_om_ok(ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=800) + ops_manager.get_om_tester().assert_healthiness() diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_appdb_scram.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_appdb_scram.py new file mode 100644 index 000000000..39487d859 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_appdb_scram.py @@ -0,0 +1,251 @@ +from typing import Optional + +import pytest +from pytest import fixture + +from kubetester.kubetester import ( + skip_if_local, + fixture as yaml_fixture, + KubernetesTester, +) +from kubetester.mongodb import Phase +from kubetester.opsmanager import MongoDBOpsManager + +OM_RESOURCE_NAME = "om-scram" +OM_USER_NAME = "mongodb-ops-manager" +USER_DEFINED_PASSWORD = "@my-scram-password#:" +UPDATED_USER_DEFINED_PASSWORD = f"updated-{USER_DEFINED_PASSWORD}" +EXPECTED_OM_USER_ROLES = { + ("admin", "readWriteAnyDatabase"), + ("admin", "dbAdminAnyDatabase"), + ("admin", "clusterMonitor"), + ("admin", "hostManager"), + ("admin", "backup"), + ("admin", "restore"), +} + + +@fixture(scope="module") +def ops_manager(namespace: str, custom_version: Optional[str], custom_appdb_version: str) -> MongoDBOpsManager: + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml(yaml_fixture("om_appdb_scram.yaml"), namespace=namespace) + resource.set_version(custom_version) + resource.set_appdb_version(custom_appdb_version) + + return resource.create() + + +@fixture(scope="module") +def auto_generated_password(ops_manager: MongoDBOpsManager) -> str: + return ops_manager.read_appdb_generated_password() + + +@pytest.mark.e2e_om_appdb_scram +class TestOpsManagerCreation: + """ + Creates an Ops Manager instance with AppDB of size 3. This test waits until Ops Manager + is ready to avoid changing password before Ops Manager has reached ready state + """ + + def test_appdb(self, ops_manager: MongoDBOpsManager, custom_appdb_version: str): + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=600) + + assert ops_manager.appdb_status().get_members() == 3 + assert ops_manager.appdb_status().get_version() == custom_appdb_version + + def test_admin_config_map(self, ops_manager: MongoDBOpsManager): + ops_manager.get_automation_config_tester().reached_version(1) + + def test_ops_manager_spec(self, ops_manager: MongoDBOpsManager): + """security (and authentication inside it) are not show in spec""" + assert "security" not in ops_manager["spec"]["applicationDatabase"] + + @skip_if_local + def test_mongod(self, ops_manager: MongoDBOpsManager, custom_appdb_version: str): + mdb_tester = ops_manager.get_appdb_tester() + mdb_tester.assert_connectivity() + mdb_tester.assert_version(custom_appdb_version) + + def test_appdb_automation_config(self, ops_manager: MongoDBOpsManager): + # only user should be the Ops Manager user + tester = ops_manager.get_automation_config_tester() + tester.assert_authentication_mechanism_enabled("SCRAM-SHA-256", False) + tester.assert_has_user(OM_USER_NAME) + tester.assert_user_has_roles(OM_USER_NAME, EXPECTED_OM_USER_ROLES) + tester.assert_expected_users(1) + tester.assert_authoritative_set(False) + + @skip_if_local + def test_scram_secrets_exists_with_correct_owner_reference(self, ops_manager: MongoDBOpsManager): + password_secret = ops_manager.read_appdb_agent_password_secret() + keyfile_secret = ops_manager.read_appdb_agent_keyfile_secret() + omUID = ops_manager.backing_obj["metadata"]["uid"] + + assert len(password_secret.metadata.owner_references) == 1 + assert password_secret.metadata.owner_references[0].uid == omUID + + assert len(keyfile_secret.metadata.owner_references) == 1 + assert keyfile_secret.metadata.owner_references[0].uid == omUID + + @skip_if_local + def test_appdb_scram_sha(self, ops_manager: MongoDBOpsManager, auto_generated_password: str): + app_db_tester = ops_manager.get_appdb_tester() + + # should be possible to auth as the operator will have auto generated a password + app_db_tester.assert_scram_sha_authentication( + OM_USER_NAME, auto_generated_password, auth_mechanism="SCRAM-SHA-256" + ) + + def test_om_is_created(self, ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_reaches_phase(phase=Phase.Running, timeout=700) + # Let the monitoring get registered + ops_manager.appdb_status().assert_abandons_phase(Phase.Running, timeout=100) + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=300) + + +@pytest.mark.e2e_om_appdb_scram +class TestChangeOpsManagerUserPassword: + """ + Creates a secret with a new password that the Ops Manager user should use and ensures that + SCRAM is configured correctly with the new password + """ + + def test_upgrade_om(self, ops_manager: MongoDBOpsManager): + ops_manager.load() + KubernetesTester.create_secret(ops_manager.namespace, "my-password", {"new-key": USER_DEFINED_PASSWORD}) + + ops_manager["spec"]["applicationDatabase"]["passwordSecretKeyRef"] = { + "name": "my-password", + "key": "new-key", + } + ops_manager.update() + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=600) + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=800) + + @pytest.mark.xfail(reason="the auto generated password should have been deleted once the user creates their own") + def test_auto_generated_password_exists(self, ops_manager: MongoDBOpsManager): + ops_manager.read_appdb_generated_password_secret() + + def test_config_map_reached_v2(self, ops_manager: MongoDBOpsManager): + # should reach version 2 as a password has changed, resulting in new ScramShaCreds + ops_manager.get_automation_config_tester().reached_version(2) + + def test_appdb_automation_config(self, ops_manager: MongoDBOpsManager): + tester = ops_manager.get_automation_config_tester() + tester.assert_authentication_mechanism_enabled("SCRAM-SHA-256", False) + tester.assert_has_user(OM_USER_NAME) + tester.assert_user_has_roles(OM_USER_NAME, EXPECTED_OM_USER_ROLES) + tester.assert_expected_users(1) + tester.assert_authoritative_set(False) + + @skip_if_local + def test_authenticate_with_user_password(self, ops_manager: MongoDBOpsManager): + app_db_tester = ops_manager.get_appdb_tester() + password = KubernetesTester.read_secret(ops_manager.namespace, "my-password")["new-key"] + assert password == USER_DEFINED_PASSWORD + app_db_tester.assert_scram_sha_authentication(OM_USER_NAME, password, auth_mechanism="SCRAM-SHA-256") + + @skip_if_local + def test_cannot_authenticate_with_old_autogenerated_password( + self, ops_manager: MongoDBOpsManager, auto_generated_password: str + ): + app_db_tester = ops_manager.get_appdb_tester() + app_db_tester.assert_scram_sha_authentication_fails( + OM_USER_NAME, auto_generated_password, auth_mechanism="SCRAM-SHA-256" + ) + + +@pytest.mark.e2e_om_appdb_scram +class TestChangeOpsManagerExistingUserPassword: + """ + Updating the secret should trigger another reconciliation because the + Operator should be watching the user created secret. + """ + + def test_user_update_password(self, namespace: str): + KubernetesTester.update_secret( + namespace, + "my-password", + {"new-key": UPDATED_USER_DEFINED_PASSWORD}, + ) + + def test_om_reconciled(self, ops_manager: MongoDBOpsManager): + # OM got reconciled on secret change + ops_manager.om_status().assert_abandons_phase(Phase.Running) + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=800) + + @pytest.mark.xfail(reason="the auto generated password should have been deleted once the user creates their own") + def test_auto_generated_password_exists(self, ops_manager: MongoDBOpsManager): + ops_manager.read_appdb_generated_password_secret() + + def test_config_map_reached_v3(self, ops_manager: MongoDBOpsManager): + # should reach version 3 as a password has changed, resulting in new ScramShaCreds + assert ops_manager.get_automation_config_tester().reached_version(3) + + def test_appdb_automation_config(self, ops_manager: MongoDBOpsManager): + tester = ops_manager.get_automation_config_tester() + tester.assert_authentication_mechanism_enabled("SCRAM-SHA-256", False) + tester.assert_has_user(OM_USER_NAME) + tester.assert_user_has_roles(OM_USER_NAME, EXPECTED_OM_USER_ROLES) + tester.assert_authoritative_set(False) + tester.assert_expected_users(1) + + @skip_if_local + def test_authenticate_with_user_password(self, ops_manager: MongoDBOpsManager): + app_db_tester = ops_manager.get_appdb_tester() + password = KubernetesTester.read_secret(ops_manager.namespace, "my-password")["new-key"] + assert password == UPDATED_USER_DEFINED_PASSWORD + app_db_tester.assert_scram_sha_authentication(OM_USER_NAME, password, auth_mechanism="SCRAM-SHA-256") + + @skip_if_local + def test_cannot_authenticate_with_old_autogenerated_password( + self, ops_manager: MongoDBOpsManager, auto_generated_password: str + ): + app_db_tester = ops_manager.get_appdb_tester() + app_db_tester.assert_scram_sha_authentication_fails( + OM_USER_NAME, auto_generated_password, auth_mechanism="SCRAM-SHA-256" + ) + + +@pytest.mark.e2e_om_appdb_scram +class TestOpsManagerGeneratesNewPasswordIfNoneSpecified: + """ + name: Fall back to auto generated password + description: | + Creates a secret with a new password that the Ops Manager user should use and ensures that + SCRAM is configured correctly with the new password + update: + file: om_appdb_scram.yaml + patch: '[{"op":"add","path":"/spec/applicationDatabase/passwordSecretKeyRef","value": {"name": "", "key": ""}}]' + wait_until: om_in_running_state + timeout: 1200 + """ + + def test_upgrade_om(self, ops_manager: MongoDBOpsManager): + ops_manager.load() + ops_manager["spec"]["applicationDatabase"]["passwordSecretKeyRef"] = { + "name": "", + "key": "", + } + ops_manager.update() + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=900) + + def test_new_password_was_created(self, ops_manager: MongoDBOpsManager): + assert ops_manager.read_appdb_generated_password() != "" + + def test_wait_for_config_map_reached_v4(self, ops_manager: MongoDBOpsManager): + # should reach version 4 as password should change back + assert ops_manager.get_automation_config_tester().reached_version(4) + + @skip_if_local + def test_cannot_authenticate_with_old_password(self, ops_manager: MongoDBOpsManager): + app_db_tester = ops_manager.get_appdb_tester() + app_db_tester.assert_scram_sha_authentication_fails( + OM_USER_NAME, USER_DEFINED_PASSWORD, auth_mechanism="SCRAM-SHA-256" + ) + + @skip_if_local + def test_authenticate_with_user_password(self, ops_manager: MongoDBOpsManager, auto_generated_password: str): + app_db_tester = ops_manager.get_appdb_tester() + password = ops_manager.read_appdb_generated_password() + assert password != auto_generated_password, "new password should have been generated" + app_db_tester.assert_scram_sha_authentication(OM_USER_NAME, password, auth_mechanism="SCRAM-SHA-256") diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_appdb_validation.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_appdb_validation.py new file mode 100644 index 000000000..36f5b539f --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_appdb_validation.py @@ -0,0 +1,192 @@ +from typing import Optional + +import pytest +from kubernetes.client import ApiException +from kubetester.kubetester import KubernetesTester +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.opsmanager import MongoDBOpsManager + + +def om_resource(namespace: str) -> MongoDBOpsManager: + return MongoDBOpsManager.from_yaml(yaml_fixture("om_validation.yaml"), namespace=namespace) + + +@pytest.mark.e2e_om_appdb_validation +def test_wrong_appdb_version(namespace: str, custom_version: Optional[str]): + om = om_resource(namespace) + om["spec"]["applicationDatabase"]["version"] = "3.6.12" + om.set_version(custom_version) + with pytest.raises( + ApiException, + match=r"the version of Application Database must be .* 4.0", + ): + om.create() + + +@pytest.mark.e2e_om_appdb_validation +class TestOpsManagerAppDbWrongSize(KubernetesTester): + """ + name: Wrong size of AppDb + description: | + AppDB with members < 3 is not allowed + create: + file: om_validation.yaml + patch: '[{"op":"replace","path":"/spec/applicationDatabase/members","value":2}]' + exception: 'spec.applicationDatabase.members in body should be greater than or equal to 3' + """ + + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_om_appdb_validation +class TestOpsManagerBackupEnabledNotSpecified(KubernetesTester): + """ + name: Backup 'enabled' check + description: | + Backup specified but 'enabled' field is missing - it is required + create: + file: om_validation.yaml + patch: '[{"op":"add","path":"/spec/backup","value":{}}]' + exception: 'spec.backup.enabled in body is required' + """ + + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_om_appdb_validation +class TestOpsManagerBackupOplogStoreNameRequired(KubernetesTester): + """ + name: Backup 'enabled' check + description: | + Backup oplog store specified but missing 'name' field + create: + file: om_validation.yaml + patch: '[{"op":"add","path":"/spec/backup","value":{"enabled": true, "opLogStores": [{"mongodbResourceRef": {}}]}}]' + exception: 'spec.backup.opLogStores[0].name: Required value' + """ + + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_om_appdb_validation +class TestOpsManagerBackupOplogStoreMongodbRefRequired(KubernetesTester): + """ + name: Backup 'enabled' check + description: | + Backup oplog store specified but missing 'mongodbResourceRef' field + create: + file: om_validation.yaml + patch: '[{"op":"add","path":"/spec/backup","value":{"enabled": true, "opLogStores": [{"name": "foo"}]}}]' + exception: 'spec.backup.opLogStores[0].mongodbResourceRef: Required value' + """ + + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_om_appdb_validation +class TestOpsManagerS3StoreNameRequired(KubernetesTester): + """ + description: | + S3 store specified but missing 'name' field + create: + file: om_s3store_validation.yaml + patch: '[ { "op":"add","path":"/spec/backup/s3Stores/-","value": {} }]' + exception: 'spec.backup.s3Stores[0].name: Required value' + """ + + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_om_appdb_validation +class TestOpsManagerS3StorePathStyleAccessEnabledRequired(KubernetesTester): + """ + description: | + S3 store specified but missing 'pathStyleAccessEnabled' field + create: + file: om_s3store_validation.yaml + patch: '[{"op":"add","path":"/spec/backup/s3Stores/-","value":{ "name": "foo", "mongodbResourceRef": {"name":"my-rs" }}}]' + exception: 'spec.backup.s3Stores[0].pathStyleAccessEnabled: Required value' + """ + + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_om_appdb_validation +class TestOpsManagerS3StoreS3BucketEndpointRequired(KubernetesTester): + """ + description: | + S3 store specified but missing 's3BucketEndpoint' field + create: + file: om_s3store_validation.yaml + patch: '[{"op":"add","path":"/spec/backup/s3Stores/-","value":{ "name": "foo", "mongodbResourceRef": {"name":"my-rs" }, "pathStyleAccessEnabled": true }}]' + exception: 'spec.backup.s3Stores[0].s3BucketEndpoint: Required value' + """ + + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_om_appdb_validation +class TestOpsManagerS3StoreS3BucketNameRequired(KubernetesTester): + """ + description: | + S3 store specified but missing 's3BucketName' field + create: + file: om_s3store_validation.yaml + patch: '[{"op":"add","path":"/spec/backup/s3Stores/-","value":{ "name": "foo", "mongodbResourceRef": {"name":"my-rs" }, "pathStyleAccessEnabled": true , "s3BucketEndpoint": "my-endpoint"}}]' + exception: 'spec.backup.s3Stores[0].s3BucketName: Required value' + """ + + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_om_appdb_validation +class TestOpsManagerS3StoreS3SecretRequired(KubernetesTester): + """ + description: | + S3 store specified but missing 's3SecretRef' field + create: + file: om_s3store_validation.yaml + patch: '[{"op":"add","path":"/spec/backup/s3Stores/-","value":{ "name": "foo", "mongodbResourceRef": {"name":"my-rs" }, "pathStyleAccessEnabled": true , "s3BucketEndpoint": "my-endpoint", "s3BucketName": "bucket-name"}}]' + exception: 'spec.backup.s3Stores[0].s3SecretRef: Required value' + """ + + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_om_appdb_validation +class TestOpsManagerExternalConnectivityTypeRequired(KubernetesTester): + """ + description: | + 'spec.externalConnectivity.type' is a required field + create: + file: om_validation.yaml + patch: '[{"op":"add","path":"/spec/externalConnectivity","value":{}}]' + exception: 'spec.externalConnectivity.type: Required value' + """ + + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_om_appdb_validation +class TestOpsManagerExternalConnectivityWrongType(KubernetesTester): + """ + description: | + 'spec.externalConnectivity.type' must be either "LoadBalancer" or "NodePort" + create: + file: om_validation.yaml + patch: '[{"op":"add","path":"/spec/externalConnectivity","value":{"type": "nginx"}}]' + exception: 'spec.externalConnectivity.type in body should be one of [LoadBalancer NodePort]' + """ + + def test_validation_ok(self): + assert True diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_external_connectivity.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_external_connectivity.py new file mode 100644 index 000000000..8c83fc6c6 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_external_connectivity.py @@ -0,0 +1,131 @@ +from typing import Optional +import random + +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.mongodb import Phase +from kubetester.opsmanager import MongoDBOpsManager +from pytest import fixture, mark + + +@fixture(scope="module") +def opsmanager( + namespace: str, custom_version: Optional[str], custom_appdb_version: str +) -> MongoDBOpsManager: + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_basic.yaml"), namespace=namespace + ) + resource.set_version(custom_version) + resource.set_appdb_version(custom_appdb_version) + + yield resource.create() + + +@mark.e2e_om_external_connectivity +def test_reaches_goal_state(opsmanager: MongoDBOpsManager): + opsmanager.om_status().assert_reaches_phase(Phase.Running, timeout=600) + # some time for monitoring to be finished + opsmanager.appdb_status().assert_abandons_phase(Phase.Running, timeout=100) + opsmanager.appdb_status().assert_reaches_phase(Phase.Running, timeout=300) + opsmanager.om_status().assert_reaches_phase(Phase.Running, timeout=50) + + internal, external = opsmanager.services() + assert internal is not None + assert external is None + + assert internal.spec.type == "ClusterIP" + assert internal.spec.cluster_ip == "None" + + +@mark.e2e_om_external_connectivity +def test_set_external_connectivity(opsmanager: MongoDBOpsManager): + # TODO: The loadBalancerIP being set to 1.2.3.4 will not allow for this + # LoadBalancer to work in Kops. + ext_connectivity = { + "type": "LoadBalancer", + "loadBalancerIP": "1.2.3.4", + "externalTrafficPolicy": "Local", + "annotations": { + "first-annotation": "first-value", + "second-annotation": "second-value", + }, + } + opsmanager.load() + opsmanager["spec"]["externalConnectivity"] = ext_connectivity + opsmanager.update() + + opsmanager.om_status().assert_reaches_phase(Phase.Running) + + internal, external = opsmanager.services() + + assert internal is not None + assert internal.spec.type == "ClusterIP" + assert internal.spec.cluster_ip == "None" + + assert external is not None + assert external.spec.type == "LoadBalancer" + assert external.spec.load_balancer_ip == "1.2.3.4" + assert external.spec.external_traffic_policy == "Local" + + +@mark.e2e_om_external_connectivity +def test_add_annotations(opsmanager: MongoDBOpsManager): + """Makes sure annotations are updated properly.""" + annotations = {"second-annotation": "edited-value", "added-annotation": "new-value"} + opsmanager.load() + opsmanager["spec"]["externalConnectivity"]["annotations"] = annotations + opsmanager.update() + + opsmanager.om_status().assert_reaches_phase(Phase.Running) + + internal, external = opsmanager.services() + + ant = external.metadata.annotations + assert len(ant) == 3 + assert "first-annotation" in ant + assert "second-annotation" in ant + assert "added-annotation" in ant + + assert ant["second-annotation"] == "edited-value" + assert ant["added-annotation"] == "new-value" + + +@mark.e2e_om_external_connectivity +def test_service_set_node_port(opsmanager: MongoDBOpsManager): + """Changes externalConnectivity to type NodePort.""" + node_port = random.randint(30000, 32700) + opsmanager["spec"]["externalConnectivity"] = { + "type": "NodePort", + "port": node_port, + } + opsmanager.update() + + opsmanager.assert_reaches(lambda om: service_is_changed_to_nodeport(om)) + + internal, external = opsmanager.services() + assert internal.spec.type == "ClusterIP" + assert external.spec.type == "NodePort" + assert external.spec.ports[0].node_port == node_port + assert external.spec.ports[0].port == node_port + assert external.spec.ports[0].target_port == 8080 + + opsmanager["spec"]["externalConnectivity"] = { + "type": "LoadBalancer", + "port": node_port, + } + opsmanager.update() + + opsmanager.assert_reaches(lambda om: service_is_changed_to_loadbalancer(om)) + + _, external = opsmanager.services() + assert external.spec.type == "LoadBalancer" + assert external.spec.ports[0].node_port == node_port + assert external.spec.ports[0].port == node_port + assert external.spec.ports[0].target_port == 8080 + + +def service_is_changed_to_nodeport(om: MongoDBOpsManager) -> bool: + return om.services()[1].spec.type == "NodePort" + + +def service_is_changed_to_loadbalancer(om: MongoDBOpsManager) -> bool: + return om.services()[1].spec.type == "LoadBalancer" diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_jvm_params.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_jvm_params.py new file mode 100644 index 000000000..1c36a983b --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_jvm_params.py @@ -0,0 +1,86 @@ +from typing import Optional + +from kubetester.kubetester import fixture as yaml_fixture, KubernetesTester +from kubetester.mongodb import Phase +from kubetester.opsmanager import MongoDBOpsManager +from pytest import fixture, mark +import re + +OM_CONF_PATH_DIR = "mongodb-ops-manager/conf/mms.conf" +JAVA_MMS_UI_OPTS = "JAVA_MMS_UI_OPTS" +JAVA_DAEMON_OPTS = "JAVA_DAEMON_OPTS" + + +@fixture(scope="module") +def ops_manager(namespace: str, custom_version: Optional[str], custom_appdb_version: str) -> MongoDBOpsManager: + """The fixture for Ops Manager to be created.""" + om = MongoDBOpsManager.from_yaml(yaml_fixture("om_ops_manager_jvm_params.yaml"), namespace=namespace) + om.set_version(custom_version) + om.set_appdb_version(custom_appdb_version) + + return om.create() + + +@mark.e2e_om_jvm_params +class TestOpsManagerCreationWithJvmParams: + def test_om_created(self, ops_manager: MongoDBOpsManager): + # Backup is not fully configured so we wait until Pending phase + ops_manager.backup_status().assert_reaches_phase( + Phase.Pending, + timeout=900, + msg_regexp="Oplog Store configuration is required for backup.*", + ) + + def test_om_jvm_params_configured(self, ops_manager: MongoDBOpsManager): + pod_name = ops_manager.read_om_pods()[0].metadata.name + cmd = ["/bin/sh", "-c", "cat " + OM_CONF_PATH_DIR] + + result = KubernetesTester.run_command_in_pod_container(pod_name, ops_manager.namespace, cmd) + java_params = self.parse_java_params(result, JAVA_MMS_UI_OPTS) + assert "-Xmx4291m" in java_params + assert "-Xms343m" in java_params + + def test_om_process_mem_scales(self, ops_manager: MongoDBOpsManager): + pod_name = ops_manager.read_om_pods()[0].metadata.name + cmd = ["/bin/sh", "-c", "ps aux"] + result = KubernetesTester.run_command_in_pod_container(pod_name, ops_manager.namespace, cmd) + rss = self.parse_rss(result) + + # rss is in kb, we want to ensure that it is > 400mb + # this is to ensure that OM can grow properly with it's container + assert int(rss) / 1024 > 400 + + def test_om_jvm_backup_params_configured(self, ops_manager: MongoDBOpsManager): + pod_names = ops_manager.backup_daemon_pods_names() + assert len(pod_names) == 1 + pod_name = pod_names[0] + cmd = ["/bin/sh", "-c", "cat " + OM_CONF_PATH_DIR] + + result = KubernetesTester.run_command_in_pod_container(pod_name, ops_manager.namespace, cmd) + + java_params = self.parse_java_params(result, JAVA_DAEMON_OPTS) + assert "-Xmx4352m" in java_params + assert "-Xms4352m" in java_params + + def parse_java_params(self, conf: str, opts_key: str) -> str: + java_params = "" + for line in conf.split("\n"): + if not line.startswith("#") and opts_key in line: + param_line = line.split("=", 1) + assert len(param_line) != 0, "Expected key=value format" + java_params = param_line[1] + + return java_params + + def parse_rss(self, ps_output: str) -> int: + rss = 0 + sep = re.compile("[\s]+") + + for row in ps_output.split("\n"): + columns = sep.split(row) + if "ops-manager" in columns[10]: + # RSS + rss = int(columns[5]) + break + + return rss diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_localmode_multiple_pv.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_localmode_multiple_pv.py new file mode 100644 index 000000000..3a13d00c8 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_localmode_multiple_pv.py @@ -0,0 +1,112 @@ +from typing import Optional + +from kubetester.kubetester import ( + fixture as yaml_fixture, + KubernetesTester, +) +from kubetester import get_default_storage_class +from kubetester.mongodb import Phase, MongoDB +from kubetester.opsmanager import MongoDBOpsManager +from pytest import fixture, mark + + +@fixture(scope="module") +def ops_manager( + namespace: str, custom_version: Optional[str], custom_appdb_version: str +) -> MongoDBOpsManager: + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_localmode-multiple-pv.yaml"), namespace=namespace + ) + resource.set_version(custom_version) + resource.set_appdb_version(custom_appdb_version) + resource.allow_mdb_rc_versions() + + return resource.create() + + +@fixture(scope="module") +def replica_set( + ops_manager: MongoDBOpsManager, namespace: str, custom_mdb_version: str +) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + ).configure(ops_manager, "my-replica-set") + resource["spec"]["version"] = custom_mdb_version + resource["spec"]["members"] = 2 + + yield resource.create() + + +@mark.e2e_om_localmode_multiple_pv +class TestOpsManagerCreation: + def test_ops_manager_ready(self, ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=1000) + + def test_volume_mounts(self, ops_manager: MongoDBOpsManager): + statefulset = ops_manager.read_statefulset() + + # pod template has volume mount request + assert ("/mongodb-ops-manager/mongodb-releases", "mongodb-versions") in ( + (mount.mount_path, mount.name) + for mount in statefulset.spec.template.spec.containers[0].volume_mounts + ) + + def test_pvcs(self, ops_manager: MongoDBOpsManager): + + for pod in ops_manager.read_om_pods(): + claims = [ + volume + for volume in pod.spec.volumes + if getattr(volume, "persistent_volume_claim") + ] + assert len(claims) == 1 + + KubernetesTester.check_single_pvc( + namespace=ops_manager.namespace, + volume=claims[0], + expected_name="mongodb-versions", + expected_claim_name="mongodb-versions-{}".format(pod.metadata.name), + expected_size="20G", + storage_class=get_default_storage_class(), + ) + + def test_replica_set_reaches_failed_phase(self, replica_set: MongoDB): + # CLOUDP-61573 - we don't get the validation error on automation config submission if the OM has no + # distros for local mode - so just wait until the agents don't reach goal state + replica_set.assert_reaches_phase(Phase.Failed, timeout=300) + + def test_add_mongodb_distros( + self, ops_manager: MongoDBOpsManager, custom_mdb_version: str + ): + ops_manager.download_mongodb_binaries(custom_mdb_version) + + def test_replica_set_reaches_running_phase(self, replica_set: MongoDB): + # note that the Replica Set may sometimes still get to Failed error + # ("Status: 400 (Bad Request), Detail: Invalid config: MongoDB version 4.2.0 is not available.") + # so we are ignoring errors during this wait + replica_set.assert_reaches_phase(Phase.Running, timeout=300, ignore_errors=True) + + def test_client_can_connect_to_mongodb( + self, replica_set: MongoDB, custom_mdb_version: str + ): + replica_set.assert_connectivity() + replica_set.tester().assert_version(custom_mdb_version) + + +@mark.e2e_om_localmode_multiple_pv +class TestOpsManagerRestarted: + def test_restart_ops_manager_pod(self, ops_manager: MongoDBOpsManager): + ops_manager.load() + ops_manager["spec"]["configuration"]["mms.testUtil.enabled"] = "false" + ops_manager.update() + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=900) + + def test_can_scale_replica_set(self, replica_set: MongoDB): + replica_set.load() + replica_set["spec"]["members"] = 4 + replica_set.update() + replica_set.assert_reaches_phase(Phase.Running, timeout=200) + + def test_client_can_still_connect(self, replica_set: MongoDB): + replica_set.assert_connectivity() diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_localmode_single_pv.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_localmode_single_pv.py new file mode 100644 index 000000000..16fc52856 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_localmode_single_pv.py @@ -0,0 +1,106 @@ +from typing import Optional + +import yaml +from kubetester.kubetester import ( + fixture as yaml_fixture, + skip_if_local, + KubernetesTester, +) +from kubetester import get_default_storage_class +from kubetester.mongodb import Phase, MongoDB +from kubetester.opsmanager import MongoDBOpsManager +from pytest import fixture, mark + +# we can use the custom_mdb_version fixture when we release mongodb-enterprise-init-mongod-rhel and +# mongodb-enterprise-init-mongod-ubuntu1604 for 4.4+ versions, so far let's use the constant +VERSION_IN_OPS_MANAGER = "4.2.8-ent" +VERSION_NOT_IN_OPS_MANAGER = "4.2.1" + + +@fixture(scope="module") +def ops_manager( + namespace: str, custom_version: Optional[str], custom_appdb_version: str +) -> MongoDBOpsManager: + with open(yaml_fixture("mongodb_versions_claim.yaml"), "r") as f: + pvc_body = yaml.safe_load(f.read()) + + KubernetesTester.create_or_update_pvc( + namespace, body=pvc_body, storage_class_name=get_default_storage_class() + ) + + """ The fixture for Ops Manager to be created.""" + om: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_localmode-single-pv.yaml"), namespace=namespace + ) + om.set_version(custom_version) + om.set_appdb_version(custom_appdb_version) + yield om.create() + + KubernetesTester.delete_pvc(namespace, "mongodb-versions-claim") + + +@fixture(scope="module") +def replica_set(ops_manager: MongoDBOpsManager, namespace: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + ).configure(ops_manager, "my-replica-set") + resource["spec"]["version"] = VERSION_IN_OPS_MANAGER + yield resource.create() + + +@mark.e2e_om_localmode +def test_ops_manager_reaches_running_phase(ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=900) + ops_manager.appdb_status().assert_abandons_phase(Phase.Running, timeout=100) + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=400) + + +@mark.e2e_om_localmode +def test_replica_set_reaches_running_phase(replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running, timeout=600) + + +@mark.e2e_om_localmode +def test_replica_set_version_upgraded_reaches_failed_phase(replica_set: MongoDB): + replica_set["spec"]["version"] = VERSION_NOT_IN_OPS_MANAGER + replica_set.update() + replica_set.assert_reaches_phase( + Phase.Failed, + msg_regexp=f".*Invalid config: MongoDB version {VERSION_NOT_IN_OPS_MANAGER} is not available.*", + ) + + +@mark.e2e_om_localmode +def test_replica_set_recovers(replica_set: MongoDB): + replica_set["spec"]["version"] = VERSION_IN_OPS_MANAGER + replica_set.update() + replica_set.assert_reaches_phase(Phase.Running, timeout=600) + + +@skip_if_local +@mark.e2e_om_localmode +def test_client_can_connect_to_mongodb(replica_set: MongoDB): + replica_set.assert_connectivity() + + +@mark.e2e_om_localmode +def test_restart_ops_manager_pod(ops_manager: MongoDBOpsManager): + ops_manager.load() + ops_manager["spec"]["configuration"]["mms.testUtil.enabled"] = "false" + ops_manager.update() + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=900) + + +@mark.e2e_om_localmode +def test_can_scale_replica_set(replica_set: MongoDB): + replica_set.load() + replica_set["spec"]["members"] = 5 + replica_set.update() + replica_set.assert_reaches_phase(Phase.Running, timeout=600) + + +@skip_if_local +@mark.e2e_om_localmode +def test_client_can_still_connect(replica_set: MongoDB): + replica_set.assert_connectivity() diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup.py new file mode 100644 index 000000000..4299a4166 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup.py @@ -0,0 +1,761 @@ +from operator import attrgetter +from typing import Optional, Dict + +from kubernetes import client +from kubernetes.client import ApiException +from pytest import mark, fixture + +from kubetester import ( + assert_pod_container_security_context, + assert_pod_security_context, + run_periodically, + create_secret, + create_or_update_secret, +) +from kubetester import get_default_storage_class, create_or_update, try_load +from kubetester.awss3client import AwsS3Client, s3_endpoint +from kubetester.kubetester import ( + skip_if_local, + fixture as yaml_fixture, + KubernetesTester, + running_locally, +) +from kubetester.mongodb import Phase, MongoDB +from kubetester.mongodb_user import MongoDBUser +from kubetester.omtester import OMTester +from kubetester.opsmanager import MongoDBOpsManager +from kubetester.test_identifiers import set_test_identifier +from tests.opsmanager.backup_snapshot_schedule_tests import BackupSnapshotScheduleTests +from tests.opsmanager.conftest import ensure_ent_version + +HEAD_PATH = "/head/" +S3_SECRET_NAME = "my-s3-secret" +AWS_REGION = "us-east-1" +OPLOG_RS_NAME = "my-mongodb-oplog" +S3_RS_NAME = "my-mongodb-s3" +BLOCKSTORE_RS_NAME = "my-mongodb-blockstore" +USER_PASSWORD = "/qwerty@!#:" + +""" +Current test focuses on backup capabilities. It creates an explicit MDBs for S3 snapshot metadata, Blockstore and Oplog +databases. Tests backup enabled for both MDB 4.0 and 4.2, snapshots created +""" + + +def new_om_s3_store( + mdb: MongoDB, + s3_id: str, + s3_bucket_name: str, + aws_s3_client: AwsS3Client, + assignment_enabled: bool = True, + path_style_access_enabled: bool = True, + user_name: Optional[str] = None, + password: Optional[str] = None, +) -> Dict: + return { + "uri": mdb.mongo_uri(user_name=user_name, password=password), + "id": s3_id, + "pathStyleAccessEnabled": path_style_access_enabled, + "s3BucketEndpoint": s3_endpoint(AWS_REGION), + "s3BucketName": s3_bucket_name, + "awsAccessKey": aws_s3_client.aws_access_key, + "awsSecretKey": aws_s3_client.aws_secret_access_key, + "assignmentEnabled": assignment_enabled, + } + + +def new_om_data_store( + mdb: MongoDB, + id: str, + assignment_enabled: bool = True, + user_name: Optional[str] = None, + password: Optional[str] = None, +) -> Dict: + return { + "id": id, + "uri": mdb.mongo_uri(user_name=user_name, password=password), + "ssl": mdb.is_tls_enabled(), + "assignmentEnabled": assignment_enabled, + } + + +@fixture(scope="module") +def s3_bucket(aws_s3_client: AwsS3Client, namespace: str) -> str: + create_aws_secret(aws_s3_client, S3_SECRET_NAME, namespace) + yield from create_s3_bucket(aws_s3_client, "test-bucket-s3") + + +def create_aws_secret(aws_s3_client, secret_name: str, namespace: str, api_client: Optional[client.ApiClient] = None): + create_or_update_secret( + namespace, + secret_name, + { + "accessKey": aws_s3_client.aws_access_key, + "secretKey": aws_s3_client.aws_secret_access_key, + }, + api_client=api_client, + ) + print("\nCreated a secret for S3 credentials", secret_name) + + +def create_s3_bucket(aws_s3_client, bucket_prefix: str = "test-bucket-"): + """creates a s3 bucket and a s3 config""" + bucket_name = set_test_identifier( + f"create_s3_bucket_{bucket_prefix}", + KubernetesTester.random_k8s_name(bucket_prefix), + ) + + try: + aws_s3_client.create_s3_bucket(bucket_name) + print("Created S3 bucket", bucket_name) + + yield bucket_name + if not running_locally(): + print("\nRemoving S3 bucket", bucket_name) + aws_s3_client.delete_s3_bucket(bucket_name) + except Exception as e: + if running_locally(): + print(f"Local run: skipping creating bucket {bucket_name} because it already exists.") + yield bucket_name + else: + raise e + + +@fixture(scope="module") +def ops_manager( + namespace: str, + s3_bucket: str, + custom_version: Optional[str], + custom_appdb_version: str, +) -> MongoDBOpsManager: + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_backup.yaml"), namespace=namespace + ) + + try_load(resource) + return resource + + +@fixture(scope="module") +def oplog_replica_set(ops_manager, namespace, custom_mdb_version: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=OPLOG_RS_NAME, + ).configure(ops_manager, "development") + resource["spec"]["version"] = custom_mdb_version + + # TODO: Remove when CLOUDP-60443 is fixed + # This test will update oplog to have SCRAM enabled + # Currently this results in OM failure when enabling backup for a project, backup seems to do some caching resulting in the + # mongoURI not being updated unless pod is killed. This is documented in CLOUDP-60443, once resolved this skip & comment can be deleted + resource["spec"]["security"] = {"authentication": {"enabled": True, "modes": ["SCRAM"]}} + + yield create_or_update(resource) + + +@fixture(scope="module") +def s3_replica_set(ops_manager, namespace) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=S3_RS_NAME, + ).configure(ops_manager, "s3metadata") + + yield create_or_update(resource) + + +@fixture(scope="module") +def blockstore_replica_set(ops_manager, namespace, custom_mdb_version: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=BLOCKSTORE_RS_NAME, + ).configure(ops_manager, "blockstore") + resource["spec"]["version"] = custom_mdb_version + yield create_or_update(resource) + + +@fixture(scope="module") +def blockstore_user(namespace, blockstore_replica_set: MongoDB) -> MongoDBUser: + """Creates a password secret and then the user referencing it""" + resource = MongoDBUser.from_yaml(yaml_fixture("scram-sha-user-backing-db.yaml"), namespace=namespace) + resource["spec"]["mongodbResourceRef"]["name"] = blockstore_replica_set.name + + print(f"\nCreating password for MongoDBUser {resource.name} in secret/{resource.get_secret_name()} ") + KubernetesTester.create_secret( + KubernetesTester.get_namespace(), + resource.get_secret_name(), + { + "password": USER_PASSWORD, + }, + ) + + yield create_or_update(resource) + + +@fixture(scope="module") +def oplog_user(namespace, oplog_replica_set: MongoDB) -> MongoDBUser: + """Creates a password secret and then the user referencing it""" + resource = MongoDBUser.from_yaml( + yaml_fixture("scram-sha-user-backing-db.yaml"), + namespace=namespace, + name="mms-user-2", + ) + resource["spec"]["mongodbResourceRef"]["name"] = oplog_replica_set.name + resource["spec"]["passwordSecretKeyRef"]["name"] = "mms-user-2-password" + resource["spec"]["username"] = "mms-user-2" + + print(f"\nCreating password for MongoDBUser {resource.name} in secret/{resource.get_secret_name()} ") + KubernetesTester.create_secret( + KubernetesTester.get_namespace(), + resource.get_secret_name(), + { + "password": USER_PASSWORD, + }, + ) + + yield create_or_update(resource) + + +@mark.e2e_om_ops_manager_backup +class TestOpsManagerCreation: + """ + name: Ops Manager successful creation with backup and oplog stores enabled + description: | + Creates an Ops Manager instance with backup enabled. The OM is expected to get to 'Pending' state + eventually as it will wait for oplog db to be created + """ + + def test_setup_gp2_storage_class(self): + """This is necessary for Backup HeadDB""" + KubernetesTester.make_default_gp2_storage_class() + + def test_create_om( + self, + ops_manager: MongoDBOpsManager, + s3_bucket: str, + custom_version: str, + custom_appdb_version: str, + ): + """creates a s3 bucket, s3 config and an OM resource (waits until Backup gets to Pending state)""" + + ops_manager["spec"]["backup"]["s3Stores"][0]["s3BucketName"] = s3_bucket + ops_manager["spec"]["backup"]["headDB"]["storageClass"] = get_default_storage_class() + ops_manager["spec"]["backup"]["members"] = 2 + + ops_manager.set_version(custom_version) + ops_manager.set_appdb_version(custom_appdb_version) + ops_manager.allow_mdb_rc_versions() + + create_or_update(ops_manager) + + ops_manager.backup_status().assert_reaches_phase( + Phase.Pending, + msg_regexp="The MongoDB object .+ doesn't exist", + timeout=900, + ) + + def test_daemon_statefulset(self, ops_manager: MongoDBOpsManager): + def stateful_set_becomes_ready(): + stateful_set = ops_manager.read_backup_statefulset() + return stateful_set.status.ready_replicas == 2 and stateful_set.status.current_replicas == 2 + + KubernetesTester.wait_until(stateful_set_becomes_ready, timeout=300) + + stateful_set = ops_manager.read_backup_statefulset() + # pod template has volume mount request + assert (HEAD_PATH, "head") in ( + (mount.mount_path, mount.name) for mount in stateful_set.spec.template.spec.containers[0].volume_mounts + ) + + def test_daemon_pvc(self, ops_manager: MongoDBOpsManager, namespace: str): + """Verifies the PVCs mounted to the pod""" + pods = ops_manager.read_backup_pods() + idx = 0 + for pod in pods: + claims = [volume for volume in pod.spec.volumes if getattr(volume, "persistent_volume_claim")] + assert len(claims) == 1 + claims.sort(key=attrgetter("name")) + + default_sc = get_default_storage_class() + KubernetesTester.check_single_pvc( + namespace, + claims[0], + "head", + "head-{}-{}".format(ops_manager.backup_daemon_name(), idx), + "500M", + default_sc, + ) + idx += 1 + + def test_backup_daemon_services_created(self, namespace): + """Backup creates two additional services for queryable backup""" + services = client.CoreV1Api().list_namespaced_service(namespace).items + + # If running locally in 'default' namespace, there might be more + # services on it. Let's make sure we only count those that we care of. + # For now we allow this test to fail, because it is too broad to be significant + # and it is easy to break it. + backup_services = [s for s in services if s.metadata.name.startswith("om-backup")] + + assert len(backup_services) >= 3 + + @skip_if_local + def test_om(self, ops_manager: MongoDBOpsManager): + om_tester = ops_manager.get_om_tester() + om_tester.assert_healthiness() + for pod_fqdn in ops_manager.backup_daemon_pods_fqdns(): + om_tester.assert_daemon_enabled(pod_fqdn, HEAD_PATH) + # No oplog stores were created in Ops Manager by this time + om_tester.assert_oplog_stores([]) + om_tester.assert_s3_stores([]) + + def test_generations(self, ops_manager: MongoDBOpsManager): + assert ops_manager.appdb_status().get_observed_generation() == 1 + assert ops_manager.om_status().get_observed_generation() == 1 + assert ops_manager.backup_status().get_observed_generation() == 1 + + def test_security_contexts_appdb( + self, + ops_manager: MongoDBOpsManager, + operator_installation_config: Dict[str, str], + ): + managed = operator_installation_config["managedSecurityContext"] == "true" + for pod in ops_manager.read_appdb_pods(): + assert_pod_security_context(pod, managed) + assert_pod_container_security_context(pod.spec.containers[0], managed) + + def test_security_contexts_om( + self, + ops_manager: MongoDBOpsManager, + operator_installation_config: Dict[str, str], + ): + managed = operator_installation_config["managedSecurityContext"] == "true" + for pod in ops_manager.read_om_pods(): + assert_pod_security_context(pod, managed) + assert_pod_container_security_context(pod.spec.containers[0], managed) + + +@mark.e2e_om_ops_manager_backup +class TestBackupDatabasesAdded: + """name: Creates three mongodb resources for oplog, s3 and blockstore and waits until OM resource gets to + running state""" + + def test_backup_mdbs_created( + self, + oplog_replica_set: MongoDB, + s3_replica_set: MongoDB, + blockstore_replica_set: MongoDB, + ): + """Creates mongodb databases all at once""" + oplog_replica_set.assert_reaches_phase(Phase.Running) + s3_replica_set.assert_reaches_phase(Phase.Running) + blockstore_replica_set.assert_reaches_phase(Phase.Running) + + def test_oplog_user_created(self, oplog_user: MongoDBUser): + oplog_user.assert_reaches_phase(Phase.Updated) + + def test_oplog_updated_scram_sha_enabled(self, oplog_replica_set: MongoDB): + oplog_replica_set.load() + oplog_replica_set["spec"]["security"] = {"authentication": {"enabled": True, "modes": ["SCRAM"]}} + oplog_replica_set.update() + oplog_replica_set.assert_reaches_phase(Phase.Running) + + def test_om_failed_oplog_no_user_ref(self, ops_manager: MongoDBOpsManager): + """Waits until Backup is in failed state as blockstore doesn't have reference to the user""" + ops_manager.backup_status().assert_reaches_phase( + Phase.Failed, + msg_regexp=".*is configured to use SCRAM-SHA authentication mode, the user " + "must be specified using 'mongodbUserRef'", + ) + + def test_fix_om(self, ops_manager: MongoDBOpsManager, oplog_user: MongoDBUser): + ops_manager.load() + ops_manager["spec"]["backup"]["opLogStores"][0]["mongodbUserRef"] = {"name": oplog_user.name} + ops_manager.update() + + ops_manager.backup_status().assert_reaches_phase( + Phase.Running, + timeout=200, + ignore_errors=True, + ) + + assert ops_manager.backup_status().get_message() is None + + @skip_if_local + def test_om( + self, + s3_bucket: str, + aws_s3_client: AwsS3Client, + ops_manager: MongoDBOpsManager, + oplog_replica_set: MongoDB, + s3_replica_set: MongoDB, + blockstore_replica_set: MongoDB, + oplog_user: MongoDBUser, + ): + om_tester = ops_manager.get_om_tester() + om_tester.assert_healthiness() + # Nothing has changed for daemon + + for pod_fqdn in ops_manager.backup_daemon_pods_fqdns(): + om_tester.assert_daemon_enabled(pod_fqdn, HEAD_PATH) + + om_tester.assert_block_stores([new_om_data_store(blockstore_replica_set, "blockStore1")]) + # oplog store has authentication enabled + om_tester.assert_oplog_stores( + [ + new_om_data_store( + oplog_replica_set, + "oplog1", + user_name=oplog_user.get_user_name(), + password=USER_PASSWORD, + ) + ] + ) + om_tester.assert_s3_stores([new_om_s3_store(s3_replica_set, "s3Store1", s3_bucket, aws_s3_client)]) + + def test_generations(self, ops_manager: MongoDBOpsManager): + """There have been an update to the OM spec - all observed generations are expected to be updated""" + assert ops_manager.appdb_status().get_observed_generation() == 2 + assert ops_manager.om_status().get_observed_generation() == 2 + assert ops_manager.backup_status().get_observed_generation() == 2 + + def test_security_contexts_backup( + self, + ops_manager: MongoDBOpsManager, + operator_installation_config: Dict[str, str], + ): + managed = operator_installation_config["managedSecurityContext"] == "true" + pods = ops_manager.read_backup_pods() + for pod in pods: + assert_pod_security_context(pod, managed) + assert_pod_container_security_context(pod.spec.containers[0], managed) + + +@mark.e2e_om_ops_manager_backup +class TestOpsManagerWatchesBlockStoreUpdates: + def test_om_running(self, ops_manager: MongoDBOpsManager): + ops_manager.backup_status().assert_reaches_phase(Phase.Running, timeout=40) + + def test_scramsha_enabled_for_blockstore(self, blockstore_replica_set: MongoDB): + """Enables SCRAM for the blockstore replica set. Note that until CLOUDP-67736 is fixed + the order of operations (scram first, MongoDBUser - next) is important""" + blockstore_replica_set["spec"]["security"] = {"authentication": {"enabled": True, "modes": ["SCRAM"]}} + blockstore_replica_set.update() + + # timeout of 600 is required when enabling SCRAM in mdb5.0.0 + blockstore_replica_set.assert_reaches_phase(Phase.Running, timeout=900) + + def test_blockstore_user_was_added_to_om(self, blockstore_user: MongoDBUser): + blockstore_user.assert_reaches_phase(Phase.Updated) + + def test_om_failed_oplog_no_user_ref(self, ops_manager: MongoDBOpsManager): + """Waits until Ops manager is in failed state as blockstore doesn't have reference to the user""" + ops_manager.backup_status().assert_reaches_phase( + Phase.Failed, + msg_regexp=".*is configured to use SCRAM-SHA authentication mode, the user " + "must be specified using 'mongodbUserRef'", + ) + + def test_fix_om(self, ops_manager: MongoDBOpsManager, blockstore_user: MongoDBUser): + ops_manager.load() + ops_manager["spec"]["backup"]["blockStores"][0]["mongodbUserRef"] = {"name": blockstore_user.name} + ops_manager.update() + ops_manager.backup_status().assert_reaches_phase( + Phase.Running, + timeout=200, + ignore_errors=True, + ) + assert ops_manager.backup_status().get_message() is None + + @skip_if_local + def test_om( + self, + s3_bucket: str, + aws_s3_client: AwsS3Client, + ops_manager: MongoDBOpsManager, + oplog_replica_set: MongoDB, + s3_replica_set: MongoDB, + blockstore_replica_set: MongoDB, + oplog_user: MongoDBUser, + blockstore_user: MongoDBUser, + ): + om_tester = ops_manager.get_om_tester() + om_tester.assert_healthiness() + # Nothing has changed for daemon + for pod_fqdn in ops_manager.backup_daemon_pods_fqdns(): + om_tester.assert_daemon_enabled(pod_fqdn, HEAD_PATH) + + # block store has authentication enabled + om_tester.assert_block_stores( + [ + new_om_data_store( + blockstore_replica_set, + "blockStore1", + user_name=blockstore_user.get_user_name(), + password=USER_PASSWORD, + ) + ] + ) + + # oplog has authentication enabled + om_tester.assert_oplog_stores( + [ + new_om_data_store( + oplog_replica_set, + "oplog1", + user_name=oplog_user.get_user_name(), + password=USER_PASSWORD, + ) + ] + ) + om_tester.assert_s3_stores([new_om_s3_store(s3_replica_set, "s3Store1", s3_bucket, aws_s3_client)]) + + +@mark.e2e_om_ops_manager_backup +class TestBackupForMongodb: + """This part ensures that backup for the client works correctly and the snapshot is created. + Both latest and the one before the latest are tested (as the backup process for them may differ significantly)""" + + @fixture(scope="class") + def mdb_latest(self, ops_manager: MongoDBOpsManager, namespace, custom_mdb_version: str): + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name="mdb-four-two", + ).configure(ops_manager, "firstProject") + resource["spec"]["version"] = ensure_ent_version(custom_mdb_version) + resource.configure_backup(mode="disabled") + return create_or_update(resource) + + @fixture(scope="class") + def mdb_prev(self, ops_manager: MongoDBOpsManager, namespace, custom_mdb_prev_version: str): + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name="mdb-four-zero", + ).configure(ops_manager, "secondProject") + resource["spec"]["version"] = ensure_ent_version(custom_mdb_prev_version) + resource.configure_backup(mode="disabled") + return create_or_update(resource) + + def test_mdbs_created(self, mdb_latest: MongoDB, mdb_prev: MongoDB): + mdb_latest.assert_reaches_phase(Phase.Running) + mdb_prev.assert_reaches_phase(Phase.Running) + + def test_mdbs_enable_backup(self, mdb_latest: MongoDB, mdb_prev: MongoDB): + mdb_latest.load() + mdb_latest.configure_backup(mode="enabled") + mdb_latest.update() + mdb_prev.load() + mdb_prev.configure_backup(mode="enabled") + mdb_prev.update() + mdb_prev.assert_reaches_phase(Phase.Running) + mdb_latest.assert_reaches_phase(Phase.Running) + + def test_mdbs_backuped(self, ops_manager: MongoDBOpsManager): + om_tester_first = ops_manager.get_om_tester(project_name="firstProject") + om_tester_second = ops_manager.get_om_tester(project_name="secondProject") + + # wait until a first snapshot is ready for both + om_tester_first.wait_until_backup_snapshots_are_ready(expected_count=1) + om_tester_second.wait_until_backup_snapshots_are_ready(expected_count=1) + + def test_can_transition_from_started_to_stopped(self, mdb_latest: MongoDB, mdb_prev: MongoDB): + # a direction transition from enabled to disabled is a single + # step for the operator + mdb_prev.assert_backup_reaches_status("STARTED", timeout=100) + mdb_prev.configure_backup(mode="disabled") + mdb_prev.update() + mdb_prev.assert_backup_reaches_status("STOPPED", timeout=600) + + def test_can_transition_from_started_to_terminated_0(self, mdb_latest: MongoDB, mdb_prev: MongoDB): + # a direct transition from enabled to terminated is not possible + # the operator should handle the transition from STARTED -> STOPPED -> TERMINATING + mdb_latest.assert_backup_reaches_status("STARTED", timeout=100) + mdb_latest.configure_backup(mode="terminated") + mdb_latest.update() + mdb_latest.assert_backup_reaches_status("TERMINATING", timeout=600) + + def test_backup_terminated_for_deleted_resource(self, ops_manager: MongoDBOpsManager, mdb_prev: MongoDB): + # re-enable backup + mdb_prev.configure_backup(mode="enabled") + mdb_prev["spec"]["backup"]["autoTerminateOnDeletion"] = True + mdb_prev.update() + mdb_prev.assert_backup_reaches_status("STARTED", timeout=600) + mdb_prev.delete() + + def resource_is_deleted() -> bool: + try: + mdb_prev.load() + return False + except ApiException: + return True + + # wait until the resource is deleted + run_periodically(resource_is_deleted, timeout=300) + + om_tester_second = ops_manager.get_om_tester(project_name="secondProject") + om_tester_second.wait_until_backup_snapshots_are_ready(expected_count=0) + + +@mark.e2e_om_ops_manager_backup +class TestBackupSnapshotSchedule(BackupSnapshotScheduleTests): + pass + + +@mark.e2e_om_ops_manager_backup +class TestAssignmentLabels: + @fixture(scope="class") + def project_name(self): + return "mdb-assignment-labels" + + @fixture(scope="class") + def mdb_assignment_labels( + self, + ops_manager: MongoDBOpsManager, + namespace, + project_name, + custom_mdb_version: str, + ): + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=project_name, + ).configure(ops_manager, project_name) + resource["spec"]["members"] = 1 + resource["spec"]["version"] = ensure_ent_version(custom_mdb_version) + resource["spec"]["backup"] = {} + resource["spec"]["backup"]["assignmentLabels"] = ["test"] + resource.configure_backup(mode="enabled") + return create_or_update(resource) + + @fixture(scope="class") + def mdb_assignment_labels_om_tester(self, ops_manager: MongoDBOpsManager, project_name: str) -> OMTester: + ops_manager.load() + return ops_manager.get_om_tester(project_name=project_name) + + def test_add_assignment_labels_to_the_om(self, ops_manager: MongoDBOpsManager): + ops_manager.load() + ops_manager["spec"]["backup"]["assignmentLabels"] = ["test"] + ops_manager["spec"]["backup"]["blockStores"][0]["assignmentLabels"] = ["test"] + ops_manager["spec"]["backup"]["opLogStores"][0]["assignmentLabels"] = ["test"] + ops_manager["spec"]["backup"]["s3Stores"][0]["assignmentLabels"] = ["test"] + + ops_manager.update() + ops_manager.om_status().assert_reaches_phase(Phase.Running, ignore_errors=True) + + def test_mdb_assignment_labels_created(self, mdb_assignment_labels: MongoDB): + # In order to configure backup on the project level, the labels + # on both Backup Daemons and project need to match. Otherwise, this will fail. + mdb_assignment_labels.assert_reaches_phase(Phase.Running, ignore_errors=True) + + def test_assignment_labels_in_om(self, mdb_assignment_labels_om_tester: OMTester): + # Those labels are set on the Ops Manager CR level + mdb_assignment_labels_om_tester.api_read_backup_configs() + mdb_assignment_labels_om_tester.assert_s3_stores([{"id": "s3Store1", "labels": ["test"]}]) + mdb_assignment_labels_om_tester.assert_oplog_stores([{"id": "oplog1", "labels": ["test"]}]) + mdb_assignment_labels_om_tester.assert_block_stores([{"id": "blockStore1", "labels": ["test"]}]) + # This one is set on the MongoDB CR level + assert mdb_assignment_labels_om_tester.api_backup_group()["labelFilter"] == ["test"] + + +@mark.e2e_om_ops_manager_backup +class TestBackupConfigurationAdditionDeletion: + def test_oplog_store_is_added( + self, + ops_manager: MongoDBOpsManager, + s3_bucket: str, + aws_s3_client: AwsS3Client, + oplog_replica_set: MongoDB, + s3_replica_set: MongoDB, + oplog_user: MongoDBUser, + ): + ops_manager.reload() + ops_manager["spec"]["backup"]["opLogStores"].append( + {"name": "oplog2", "mongodbResourceRef": {"name": S3_RS_NAME}} + ) + + ops_manager.update() + ops_manager.backup_status().assert_abandons_phase(Phase.Running, timeout=60) + ops_manager.backup_status().assert_reaches_phase(Phase.Running, timeout=600) + + om_tester = ops_manager.get_om_tester() + om_tester.assert_oplog_stores( + [ + new_om_data_store( + oplog_replica_set, + "oplog1", + user_name=oplog_user.get_user_name(), + password=USER_PASSWORD, + ), + new_om_data_store(s3_replica_set, "oplog2"), + ] + ) + om_tester.assert_s3_stores([new_om_s3_store(s3_replica_set, "s3Store1", s3_bucket, aws_s3_client)]) + + def test_oplog_store_is_deleted_correctly( + self, + ops_manager: MongoDBOpsManager, + s3_bucket: str, + aws_s3_client: AwsS3Client, + oplog_replica_set: MongoDB, + s3_replica_set: MongoDB, + blockstore_replica_set: MongoDB, + blockstore_user: MongoDBUser, + oplog_user: MongoDBUser, + ): + ops_manager.reload() + ops_manager["spec"]["backup"]["opLogStores"].pop() + ops_manager.update() + + ops_manager.backup_status().assert_abandons_phase(Phase.Running, timeout=60) + ops_manager.backup_status().assert_reaches_phase(Phase.Running, timeout=600) + + om_tester = ops_manager.get_om_tester() + + om_tester.assert_oplog_stores( + [ + new_om_data_store( + oplog_replica_set, + "oplog1", + user_name=oplog_user.get_user_name(), + password=USER_PASSWORD, + ) + ] + ) + om_tester.assert_s3_stores([new_om_s3_store(s3_replica_set, "s3Store1", s3_bucket, aws_s3_client)]) + om_tester.assert_block_stores( + [ + new_om_data_store( + blockstore_replica_set, + "blockStore1", + user_name=blockstore_user.get_user_name(), + password=USER_PASSWORD, + ) + ] + ) + + def test_error_on_s3store_removal( + self, + ops_manager: MongoDBOpsManager, + ): + """Removing the s3 store when there are backups running is an error""" + ops_manager.reload() + ops_manager["spec"]["backup"]["s3Stores"] = [] + ops_manager.update() + + try: + ops_manager.backup_status().assert_reaches_phase( + Phase.Failed, + msg_regexp=".*BACKUP_CANNOT_REMOVE_S3_STORE_CONFIG.*", + timeout=200, + ) + except Exception: + # Some backup internal logic: if more than 1 snapshot stores are configured in OM (CM?) then + # the random one will be picked for backup of mongodb resource. + # There's no way to specify the config to be used when enabling backup for a mongodb deployment. + # So this means that either the removal of s3 will fail as it's used already or it will succeed as + # the blockstore snapshot was used instead and the OM would get to Running phase + assert ops_manager.backup_status().get_phase() == Phase.Running diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_delete_sts.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_delete_sts.py new file mode 100644 index 000000000..bd7201efd --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_delete_sts.py @@ -0,0 +1,85 @@ +from typing import Optional + +from kubetester import MongoDB +from kubetester.opsmanager import MongoDBOpsManager +from kubetester.kubetester import fixture as yaml_fixture + +from kubetester.mongodb import Phase +from pytest import mark, fixture +from tests.opsmanager.om_ops_manager_backup import ( + OPLOG_RS_NAME, + BLOCKSTORE_RS_NAME, +) + +DEFAULT_APPDB_USER_NAME = "mongodb-ops-manager" + + +@fixture(scope="module") +def oplog_replica_set(ops_manager, namespace) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=OPLOG_RS_NAME, + ).configure(ops_manager, "development") + + return resource.create() + + +@fixture(scope="module") +def ops_manager( + namespace: str, + custom_version: Optional[str], + custom_appdb_version: str, +) -> MongoDBOpsManager: + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_backup_delete_sts.yaml"), namespace=namespace + ) + resource.set_version(custom_version) + resource.set_appdb_version(custom_appdb_version) + + return resource.create() + + +@fixture(scope="module") +def blockstore_replica_set( + ops_manager, +) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=ops_manager.namespace, + name=BLOCKSTORE_RS_NAME, + ).configure(ops_manager, "blockstore") + + return resource.create() + + +@mark.e2e_om_ops_manager_backup_delete_sts +def test_create_om(ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=900) + + +@mark.e2e_om_ops_manager_backup_delete_sts +def test_create_backing_replica_sets( + oplog_replica_set: MongoDB, blockstore_replica_set: MongoDB +): + oplog_replica_set.assert_reaches_phase(Phase.Running) + blockstore_replica_set.assert_reaches_phase(Phase.Running) + + +@mark.e2e_om_ops_manager_backup_delete_sts +def test_backup_statefulset_gets_recreated( + ops_manager: MongoDBOpsManager, +): + # Wait for the the backup to be fully running + ops_manager.backup_status().assert_reaches_phase(Phase.Running, timeout=200) + ops_manager.load() + ops_manager["spec"]["backup"]["statefulSet"] = { + "spec": {"revisionHistoryLimit": 15} + } + ops_manager.update() + + ops_manager.backup_status().assert_abandons_phase(Phase.Running, timeout=200) + + ops_manager.backup_status().assert_reaches_phase(Phase.Running, timeout=200) + # the backup statefulset should have been recreated + ops_manager.read_backup_statefulset() diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_irsa.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_irsa.py new file mode 100644 index 000000000..91d3e8d4b --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_irsa.py @@ -0,0 +1,497 @@ +from operator import attrgetter +from typing import Optional, Dict, Callable +import subprocess +from kubernetes import client +from kubetester import get_default_storage_class +from kubetester.awss3client import AwsS3Client, s3_endpoint +from kubetester.kubetester import ( + skip_if_local, + fixture as yaml_fixture, + KubernetesTester, +) +from kubetester.mongodb import Phase, MongoDB +from kubetester.mongodb_user import MongoDBUser +from kubetester.opsmanager import MongoDBOpsManager +from kubetester import ( + assert_pod_container_security_context, + assert_pod_security_context, +) +from pytest import mark, fixture, skip +from tests.opsmanager.conftest import ensure_ent_version + +HEAD_PATH = "/head/" +AWS_REGION = "eu-west-1" +OPLOG_RS_NAME = "my-mongodb-oplog" +S3_RS_NAME = "my-mongodb-s3" +USER_PASSWORD = "/qwerty@!#:" + +""" +Current test focuses on backup capabilities. It creates an explicit MDBs for S3 snapshot metadata, and Oplog +databases. Tests backup enabled for both MDB 4.0 and 4.2, snapshots created +""" + + +def create_service_account_with_irsa(namespace: str): + # --cluster flag is the name of the EKS cluster, here the cluster name is same as the one used in documentation + cmd = """ + eksctl create iamserviceaccount \ + --name mongodb-enterprise-ops-manager \ + --namespace {} \ + --cluster irptest1 \ + --attach-policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess \ + --approve + """.format( + namespace + ) + process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE) + process.wait() + print(process.returncode) + + +def new_om_s3_store( + mdb: MongoDB, + s3_id: str, + aws_s3_client: AwsS3Client, + assignment_enabled: bool = True, + path_style_access_enabled: bool = True, + user_name: Optional[str] = None, + password: Optional[str] = None, +) -> Dict: + return { + "uri": mdb.mongo_uri(user_name=user_name, password=password), + "id": s3_id, + "pathStyleAccessEnabled": path_style_access_enabled, + "s3BucketEndpoint": s3_endpoint(AWS_REGION), + "s3BucketName": "irp-test-2023", + "awsAccessKey": aws_s3_client.aws_access_key, + "awsSecretKey": aws_s3_client.aws_secret_access_key, + "assignmentEnabled": assignment_enabled, + "irsaEnabled": True, + } + + +def new_om_data_store( + mdb: MongoDB, + id: str, + assignment_enabled: bool = True, + user_name: Optional[str] = None, + password: Optional[str] = None, +) -> Dict: + return { + "id": id, + "uri": mdb.mongo_uri(user_name=user_name, password=password), + "ssl": mdb.is_tls_enabled(), + "assignmentEnabled": assignment_enabled, + } + + +@fixture(scope="module") +def ops_manager( + namespace: str, + custom_version: Optional[str], + custom_appdb_version: str, +) -> MongoDBOpsManager: + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_backup_irsa.yaml"), namespace=namespace + ) + + resource["spec"]["backup"]["s3Stores"][0]["s3BucketName"] = "irp-test-2023" + resource["spec"]["backup"]["s3Stores"][0]["irsaEnabled"] = True + resource["spec"]["backup"]["headDB"]["storageClass"] = get_default_storage_class() + resource["spec"]["backup"]["members"] = 1 + + resource.set_version(custom_version) + resource.set_appdb_version(custom_appdb_version) + resource.allow_mdb_rc_versions() + + yield resource.create() + + +@fixture(scope="module") +def oplog_replica_set(ops_manager, namespace, custom_mdb_version: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=OPLOG_RS_NAME, + ).configure(ops_manager, "development") + resource["spec"]["version"] = custom_mdb_version + resource["spec"]["security"] = { + "authentication": {"enabled": True, "modes": ["SCRAM"]} + } + + yield resource.create() + + +@fixture(scope="module") +def s3_replica_set(ops_manager, namespace) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=S3_RS_NAME, + ).configure(ops_manager, "s3metadata") + + yield resource.create() + + +@fixture(scope="module") +def oplog_user(namespace, oplog_replica_set: MongoDB) -> MongoDBUser: + """Creates a password secret and then the user referencing it""" + resource = MongoDBUser.from_yaml( + yaml_fixture("scram-sha-user-backing-db.yaml"), + namespace=namespace, + name="mms-user-2", + ) + resource["spec"]["mongodbResourceRef"]["name"] = oplog_replica_set.name + resource["spec"]["passwordSecretKeyRef"]["name"] = "mms-user-2-password" + resource["spec"]["username"] = "mms-user-2" + + print( + f"\nCreating password for MongoDBUser {resource.name} in secret/{resource.get_secret_name()} " + ) + KubernetesTester.create_secret( + KubernetesTester.get_namespace(), + resource.get_secret_name(), + { + "password": USER_PASSWORD, + }, + ) + + yield resource.create() + + +@mark.e2e_om_ops_manager_backup_irsa +class TestOpsManagerCreation: + """ + name: Ops Manager successful creation with backup and oplog stores enabled + description: | + Creates an Ops Manager instance with backup enabled. The OM is expected to get to 'Pending' state + eventually as it will wait for oplog db to be created + """ + + def test_setup_gp2_storage_class(self): + """This is necessary for Backup HeadDB""" + KubernetesTester.make_default_gp2_storage_class() + + def test_update_service_account(self): + create_service_account_with_irsa() + + def test_create_om(self, ops_manager: MongoDBOpsManager): + """creates a s3 bucket, s3 config and an OM resource (waits until Backup gets to Pending state)""" + ops_manager.backup_status().assert_reaches_phase( + Phase.Pending, + msg_regexp="The MongoDB object .+ doesn't exist", + timeout=900, + ) + + def test_daemon_statefulset(self, ops_manager: MongoDBOpsManager): + def stateful_set_becomes_ready(): + stateful_set = ops_manager.read_backup_statefulset() + return ( + stateful_set.status.ready_replicas == 1 + and stateful_set.status.current_replicas == 1 + ) + + KubernetesTester.wait_until(stateful_set_becomes_ready, timeout=300) + + stateful_set = ops_manager.read_backup_statefulset() + # pod template has volume mount request + assert (HEAD_PATH, "head") in ( + (mount.mount_path, mount.name) + for mount in stateful_set.spec.template.spec.containers[0].volume_mounts + ) + + def test_backup_daemon_services_created(self, namespace): + """Backup creates two additional services for queryable backup""" + services = client.CoreV1Api().list_namespaced_service(namespace).items + + # If running locally in 'default' namespace, there might be more + # services on it. Let's make sure we only count those that we care of. + # For now we allow this test to fail, because it is too broad to be significant + # and it is easy to break it. + backup_services = [ + s for s in services if s.metadata.name.startswith("om-backup") + ] + + assert len(backup_services) >= 2 + + @skip_if_local + def test_om(self, ops_manager: MongoDBOpsManager): + om_tester = ops_manager.get_om_tester() + om_tester.assert_healthiness() + for pod_fqdn in ops_manager.backup_daemon_pods_fqdns(): + om_tester.assert_daemon_enabled(pod_fqdn, HEAD_PATH) + # No oplog stores were created in Ops Manager by this time + om_tester.assert_oplog_stores([]) + om_tester.assert_s3_stores([]) + + def test_security_contexts_om( + self, + ops_manager: MongoDBOpsManager, + operator_installation_config: Dict[str, str], + ): + managed = operator_installation_config["managedSecurityContext"] == "true" + for pod in ops_manager.read_om_pods(): + assert_pod_security_context(pod, managed) + assert_pod_container_security_context(pod.spec.containers[0], managed) + + +@mark.e2e_om_ops_manager_backup_irsa +class TestBackupDatabasesAdded: + """name: Creates two mongodb resources for oplog, s3 and waits until OM resource gets to + running state""" + + def test_backup_mdbs_created( + self, + oplog_replica_set: MongoDB, + s3_replica_set: MongoDB, + ): + """Creates mongodb databases all at once""" + oplog_replica_set.assert_reaches_phase(Phase.Running) + s3_replica_set.assert_reaches_phase(Phase.Running) + + def test_oplog_user_created(self, oplog_user: MongoDBUser): + oplog_user.assert_reaches_phase(Phase.Updated) + + def test_oplog_updated_scram_sha_enabled(self, oplog_replica_set: MongoDB): + oplog_replica_set["spec"]["security"] = { + "authentication": {"enabled": True, "modes": ["SCRAM"]} + } + oplog_replica_set.update() + oplog_replica_set.assert_reaches_phase(Phase.Running) + + def test_om_failed_oplog_no_user_ref(self, ops_manager: MongoDBOpsManager): + """Waits until Backup is in failed state as blockstore doesn't have reference to the user""" + ops_manager.backup_status().assert_reaches_phase( + Phase.Failed, + msg_regexp=".*is configured to use SCRAM-SHA authentication mode, the user " + "must be specified using 'mongodbUserRef'", + ) + + def test_fix_om(self, ops_manager: MongoDBOpsManager, oplog_user: MongoDBUser): + ops_manager.load() + ops_manager["spec"]["backup"]["opLogStores"][0]["mongodbUserRef"] = { + "name": oplog_user.name + } + ops_manager.update() + + ops_manager.backup_status().assert_reaches_phase( + Phase.Running, + timeout=200, + ignore_errors=True, + ) + + assert ops_manager.backup_status().get_message() is None + + @skip_if_local + def test_om( + self, + s3_bucket: str, + aws_s3_client: AwsS3Client, + ops_manager: MongoDBOpsManager, + oplog_replica_set: MongoDB, + s3_replica_set: MongoDB, + oplog_user: MongoDBUser, + ): + om_tester = ops_manager.get_om_tester() + om_tester.assert_healthiness() + # Nothing has changed for daemon + + for pod_fqdn in ops_manager.backup_daemon_pods_fqdns(): + om_tester.assert_daemon_enabled(pod_fqdn, HEAD_PATH) + + # oplog store has authentication enabled + om_tester.assert_oplog_stores( + [ + new_om_data_store( + oplog_replica_set, + "oplog1", + user_name=oplog_user.get_user_name(), + password=USER_PASSWORD, + ) + ] + ) + om_tester.assert_s3_stores( + [new_om_s3_store(s3_replica_set, "s3Store1", s3_bucket, aws_s3_client)] + ) + + def test_security_contexts_backup( + self, + ops_manager: MongoDBOpsManager, + operator_installation_config: Dict[str, str], + ): + managed = operator_installation_config["managedSecurityContext"] == "true" + pods = ops_manager.read_backup_pods() + for pod in pods: + assert_pod_security_context(pod, managed) + assert_pod_container_security_context(pod.spec.containers[0], managed) + + +@mark.e2e_om_ops_manager_backup_irsa +class TestBackupForMongodb: + """This part ensures that backup for the client works correctly and the snapshot is created. + Both latest and the one before the latest are tested (as the backup process for them may differ significantly)""" + + @fixture(scope="class") + def mdb_latest( + self, ops_manager: MongoDBOpsManager, namespace, custom_mdb_version: str + ): + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name="mdb-four-two", + ).configure(ops_manager, "firstProject") + resource["spec"]["version"] = ensure_ent_version(custom_mdb_version) + resource.configure_backup(mode="disabled") + return resource.create() + + @fixture(scope="class") + def mdb_prev( + self, ops_manager: MongoDBOpsManager, namespace, custom_mdb_prev_version: str + ): + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name="mdb-four-zero", + ).configure(ops_manager, "secondProject") + resource["spec"]["version"] = ensure_ent_version(custom_mdb_prev_version) + resource.configure_backup(mode="disabled") + return resource.create() + + def test_mdbs_created(self, mdb_latest: MongoDB, mdb_prev: MongoDB): + mdb_latest.assert_reaches_phase(Phase.Running) + mdb_prev.assert_reaches_phase(Phase.Running) + + def test_mdbs_enable_backup(self, mdb_latest: MongoDB, mdb_prev: MongoDB): + mdb_latest.load() + mdb_latest.configure_backup(mode="enabled") + mdb_latest.update() + mdb_prev.load() + mdb_prev.configure_backup(mode="enabled") + mdb_prev.update() + mdb_prev.assert_reaches_phase(Phase.Running) + mdb_latest.assert_reaches_phase(Phase.Running) + + def test_mdbs_backuped(self, ops_manager: MongoDBOpsManager): + om_tester_first = ops_manager.get_om_tester(project_name="firstProject") + om_tester_second = ops_manager.get_om_tester(project_name="secondProject") + + # wait until a first snapshot is ready for both + om_tester_first.wait_until_backup_snapshots_are_ready(expected_count=1) + om_tester_second.wait_until_backup_snapshots_are_ready(expected_count=1) + + def test_can_transition_from_started_to_stopped( + self, mdb_latest: MongoDB, mdb_prev: MongoDB + ): + # a direction transition from enabled to disabled is a single + # step for the operator + mdb_prev.wait_for(reaches_backup_status("STARTED"), timeout=100) + mdb_prev.configure_backup(mode="disabled") + mdb_prev.update() + mdb_prev.wait_for(reaches_backup_status("STOPPED"), timeout=600) + + def test_can_transition_from_started_to_terminated_0( + self, mdb_latest: MongoDB, mdb_prev: MongoDB + ): + # a direct transition from enabled to terminated is not possible + # the operator should handle the transition from STARTED -> STOPPED -> TERMINATING + mdb_latest.wait_for(reaches_backup_status("STARTED"), timeout=100) + mdb_latest.configure_backup(mode="terminated") + mdb_latest.update() + mdb_latest.wait_for(reaches_backup_status("TERMINATING"), timeout=600) + + +def reaches_backup_status(expected_status: str) -> Callable[[MongoDB], bool]: + def _fn(mdb: MongoDB): + try: + return mdb["status"]["backup"]["statusName"] == expected_status + except KeyError: + return False + + return _fn + + +@mark.e2e_om_ops_manager_backup_irsa +class TestBackupConfigurationAdditionDeletion: + def test_oplog_store_is_added( + self, + ops_manager: MongoDBOpsManager, + s3_bucket: str, + aws_s3_client: AwsS3Client, + oplog_replica_set: MongoDB, + s3_replica_set: MongoDB, + oplog_user: MongoDBUser, + ): + ops_manager.reload() + ops_manager["spec"]["backup"]["opLogStores"].append( + {"name": "oplog2", "mongodbResourceRef": {"name": S3_RS_NAME}} + ) + + ops_manager.update() + ops_manager.backup_status().assert_abandons_phase(Phase.Running, timeout=60) + ops_manager.backup_status().assert_reaches_phase(Phase.Running, timeout=600) + + om_tester = ops_manager.get_om_tester() + om_tester.assert_oplog_stores( + [ + new_om_data_store( + oplog_replica_set, + "oplog1", + user_name=oplog_user.get_user_name(), + password=USER_PASSWORD, + ), + new_om_data_store(s3_replica_set, "oplog2"), + ] + ) + om_tester.assert_s3_stores( + [new_om_s3_store(s3_replica_set, "s3Store1", s3_bucket, aws_s3_client)] + ) + + def test_oplog_store_is_deleted_correctly( + self, + ops_manager: MongoDBOpsManager, + s3_bucket: str, + aws_s3_client: AwsS3Client, + oplog_replica_set: MongoDB, + s3_replica_set: MongoDB, + oplog_user: MongoDBUser, + ): + ops_manager.reload() + ops_manager["spec"]["backup"]["opLogStores"].pop() + ops_manager.update() + + ops_manager.backup_status().assert_abandons_phase(Phase.Running, timeout=60) + ops_manager.backup_status().assert_reaches_phase(Phase.Running, timeout=600) + + om_tester = ops_manager.get_om_tester() + + om_tester.assert_oplog_stores( + [ + new_om_data_store( + oplog_replica_set, + "oplog1", + user_name=oplog_user.get_user_name(), + password=USER_PASSWORD, + ) + ] + ) + om_tester.assert_s3_stores( + [new_om_s3_store(s3_replica_set, "s3Store1", s3_bucket, aws_s3_client)] + ) + + def test_error_on_s3store_removal( + self, + ops_manager: MongoDBOpsManager, + ): + """Removing the s3 store when there are backups running is an error""" + ops_manager.reload() + ops_manager["spec"]["backup"]["s3Stores"] = [] + ops_manager.update() + + try: + ops_manager.backup_status().assert_reaches_phase( + Phase.Failed, + msg_regexp=".*BACKUP_CANNOT_REMOVE_S3_STORE_CONFIG.*", + timeout=200, + ) + except Exception: + assert ops_manager.backup_status().get_phase() == Phase.Running diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_kmip.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_kmip.py new file mode 100644 index 000000000..e6bf85fec --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_kmip.py @@ -0,0 +1,157 @@ +from typing import Optional + +from pytest import fixture, mark + +from kubetester import MongoDB, read_secret, create_secret +from kubetester.awss3client import AwsS3Client +from kubetester.certs import create_tls_certs +from kubetester.kmip import KMIPDeployment +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.mongodb import Phase +from kubetester.omtester import OMTester +from kubetester.opsmanager import MongoDBOpsManager +from tests.opsmanager.conftest import ensure_ent_version +from tests.opsmanager.om_ops_manager_backup import ( + S3_SECRET_NAME, + create_aws_secret, + create_s3_bucket, +) + +TEST_DATA = {"name": "John", "address": "Highway 37", "age": 30} + +OPLOG_SECRET_NAME = S3_SECRET_NAME + "-oplog" + +MONGODB_CR_NAME = "mdb-latest" +MONGODB_CR_KMIP_TEST_PREFIX = "test-prefix" + + +@fixture(scope="module") +def kmip(issuer, issuer_ca_configmap, namespace: str) -> KMIPDeployment: + return KMIPDeployment(namespace, issuer, "ca-key-pair", issuer_ca_configmap).deploy() + + +@fixture(scope="module") +def s3_bucket(aws_s3_client: AwsS3Client, namespace: str) -> str: + create_aws_secret(aws_s3_client, S3_SECRET_NAME, namespace) + yield from create_s3_bucket(aws_s3_client) + + +@fixture(scope="module") +def oplog_s3_bucket(aws_s3_client: AwsS3Client, namespace: str) -> str: + create_aws_secret(aws_s3_client, OPLOG_SECRET_NAME, namespace) + yield from create_s3_bucket(aws_s3_client) + + +@fixture(scope="module") +def ops_manager( + namespace: str, + s3_bucket: str, + custom_version: Optional[str], + custom_appdb_version: str, + oplog_s3_bucket: str, + issuer_ca_configmap: str, +) -> MongoDBOpsManager: + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_backup_kmip.yaml"), namespace=namespace + ) + resource.set_version(custom_version) + resource.set_appdb_version(custom_appdb_version) + resource.allow_mdb_rc_versions() + resource["spec"]["backup"]["encryption"]["kmip"]["server"]["ca"] = issuer_ca_configmap + resource["spec"]["backup"]["s3Stores"][0]["s3BucketName"] = s3_bucket + + resource["spec"]["backup"]["s3OpLogStores"] = [ + { + "name": "s3Store2", + "s3SecretRef": { + "name": OPLOG_SECRET_NAME, + }, + "pathStyleAccessEnabled": True, + "s3BucketEndpoint": "s3.us-east-1.amazonaws.com", + "s3BucketName": oplog_s3_bucket, + } + ] + return resource.create() + + +@fixture(scope="module") +def mdb_latest( + ops_manager: MongoDBOpsManager, + mdb_latest_kmip_secrets, + namespace, + custom_mdb_version: str, +): + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-kmip.yaml"), + namespace=namespace, + name=MONGODB_CR_NAME, + ).configure(ops_manager, "mdbLatestProject") + resource["spec"]["version"] = ensure_ent_version(custom_mdb_version) + resource.configure_backup(mode="enabled") + return resource.create() + + +@fixture(scope="module") +def mdb_latest_kmip_secrets(aws_s3_client: AwsS3Client, namespace, issuer, issuer_ca_configmap: str) -> str: + mdb_latest_generated_kmip_certs_secret_name = create_tls_certs( + issuer, namespace, MONGODB_CR_NAME, 3, common_name=MONGODB_CR_NAME + ) + mdb_latest_generated_kmip_certs_secret = read_secret(namespace, mdb_latest_generated_kmip_certs_secret_name) + mdb_secret_name = MONGODB_CR_KMIP_TEST_PREFIX + "-" + MONGODB_CR_NAME + "-kmip-client" + create_secret( + namespace, + mdb_secret_name, + { + "tls.crt": mdb_latest_generated_kmip_certs_secret["tls.key"] + + mdb_latest_generated_kmip_certs_secret["tls.crt"], + }, + "tls", + ) + return mdb_secret_name + + +@fixture(scope="module") +def mdb_latest_test_collection(mdb_latest): + collection = mdb_latest.tester().client["testdb"] + return collection["testcollection"] + + +@fixture(scope="module") +def mdb_latest_project(ops_manager: MongoDBOpsManager) -> OMTester: + return ops_manager.get_om_tester(project_name="mdbLatestProject") + + +@mark.e2e_om_ops_manager_backup_kmip +class TestOpsManagerCreation: + def test_create_kmip(self, kmip: KMIPDeployment): + kmip.status().assert_is_running() + + def test_create_om(self, ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=900, ignore_errors=True) + + def test_mdbs_created(self, mdb_latest: MongoDB): + # Once MDB is created, the OpsManager will be redeployed which may cause HTTP errors. + # This is required to mount new secrets for KMIP. Having said that, we also need longer timeout. + mdb_latest.assert_reaches_phase(Phase.Running, timeout=1800, ignore_errors=True) + + def test_s3_oplog_created(self, ops_manager: MongoDBOpsManager): + ops_manager.backup_status().assert_reaches_phase(Phase.Running, timeout=900, ignore_errors=True) + + +@mark.e2e_om_ops_manager_backup_kmip +class TestBackupForMongodb: + def test_mdbs_created(self, mdb_latest: MongoDB): + mdb_latest.assert_reaches_phase(Phase.Running) + + def test_add_test_data(self, mdb_latest_test_collection): + mdb_latest_test_collection.insert_one(TEST_DATA) + + def test_mdbs_backed_up(self, mdb_latest_project: OMTester): + # If OM is misconfigured, this will never become ready + mdb_latest_project.wait_until_backup_snapshots_are_ready(expected_count=1) + + def test_mdbs_backup_encrypted(self, mdb_latest_project: OMTester): + # This type of testing has been agreed with Ops Manager / Backup Team + cluster_id = mdb_latest_project.get_backup_cluster_id(expected_config_count=1) + snapshots = mdb_latest_project.api_get_snapshots(cluster_id) + assert snapshots[0]["parts"][0]["encryptionEnabled"] diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_manual.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_manual.py new file mode 100644 index 000000000..eeaca0827 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_manual.py @@ -0,0 +1,398 @@ +""" + +This test is meant to be run manually. + +This is a companion test for docs/investigation/pod-is-killed-while-agent-restores.md + +""" + +from typing import Optional, Dict + +from kubetester import get_default_storage_class +from kubetester.awss3client import AwsS3Client, s3_endpoint +from kubetester.kubetester import ( + fixture as yaml_fixture, + KubernetesTester, +) +from kubetester.mongodb import Phase, MongoDB +from kubetester.mongodb_user import MongoDBUser +from kubetester.opsmanager import MongoDBOpsManager + +from pytest import mark, fixture +from tests.opsmanager.conftest import ensure_ent_version + +HEAD_PATH = "/head/" +S3_SECRET_NAME = "my-s3-secret" +AWS_REGION = "us-east-1" +OPLOG_RS_NAME = "my-mongodb-oplog" +S3_RS_NAME = "my-mongodb-s3" +BLOCKSTORE_RS_NAME = "my-mongodb-blockstore" +USER_PASSWORD = "/qwerty@!#:" + + +def new_om_s3_store( + mdb: MongoDB, + s3_id: str, + s3_bucket_name: str, + aws_s3_client: AwsS3Client, + assignment_enabled: bool = True, + path_style_access_enabled: bool = True, + user_name: Optional[str] = None, + password: Optional[str] = None, +) -> Dict: + return { + "uri": mdb.mongo_uri(user_name=user_name, password=password), + "id": s3_id, + "pathStyleAccessEnabled": path_style_access_enabled, + "s3BucketEndpoint": s3_endpoint(AWS_REGION), + "s3BucketName": s3_bucket_name, + "awsAccessKey": aws_s3_client.aws_access_key, + "awsSecretKey": aws_s3_client.aws_secret_access_key, + "assignmentEnabled": assignment_enabled, + } + + +def new_om_data_store( + mdb: MongoDB, + id: str, + assignment_enabled: bool = True, + user_name: Optional[str] = None, + password: Optional[str] = None, +) -> Dict: + return { + "id": id, + "uri": mdb.mongo_uri(user_name=user_name, password=password), + "ssl": mdb.is_tls_enabled(), + "assignmentEnabled": assignment_enabled, + } + + +@fixture(scope="module") +def s3_bucket(aws_s3_client: AwsS3Client, namespace: str) -> str: + create_aws_secret(aws_s3_client, S3_SECRET_NAME, namespace) + return create_s3_bucket(aws_s3_client) + + +def create_aws_secret(aws_s3_client, secret_name: str, namespace: str): + KubernetesTester.create_secret( + namespace, + secret_name, + { + "accessKey": aws_s3_client.aws_access_key, + "secretKey": aws_s3_client.aws_secret_access_key, + }, + ) + print("\nCreated a secret for S3 credentials", secret_name) + + +def create_s3_bucket(aws_s3_client, bucket_prefix: str = "test-bucket-"): + """creates a s3 bucket and a s3 config""" + bucket_prefix = KubernetesTester.random_k8s_name(bucket_prefix) + aws_s3_client.create_s3_bucket(bucket_prefix) + print("Created S3 bucket", bucket_prefix) + + return bucket_prefix + + +@fixture(scope="module") +def ops_manager( + namespace: str, + s3_bucket: str, + custom_version: Optional[str], + custom_appdb_version: str, +) -> MongoDBOpsManager: + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_backup.yaml"), namespace=namespace + ) + + resource["spec"]["backup"]["s3Stores"][0]["s3BucketName"] = s3_bucket + resource["spec"]["backup"]["headDB"]["storageClass"] = get_default_storage_class() + resource["spec"]["externalConnectivity"] = {"type": "LoadBalancer"} + + resource.set_version(custom_version) + resource.set_appdb_version(custom_appdb_version) + + yield resource.create() + + +@fixture(scope="module") +def oplog_replica_set(ops_manager, namespace, custom_mdb_version: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=OPLOG_RS_NAME, + ).configure(ops_manager, "development") + resource["spec"]["version"] = custom_mdb_version + + resource["spec"]["security"] = { + "authentication": {"enabled": True, "modes": ["SCRAM"]} + } + + return resource.create() + + +@fixture(scope="module") +def s3_replica_set(ops_manager, namespace) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=S3_RS_NAME, + ).configure(ops_manager, "s3metadata") + + return resource.create() + + +@fixture(scope="module") +def blockstore_replica_set(ops_manager, namespace, custom_mdb_version: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=BLOCKSTORE_RS_NAME, + ).configure(ops_manager, "blockstore") + resource["spec"]["version"] = custom_mdb_version + return resource.create() + + +@fixture(scope="module") +def blockstore_user(namespace, blockstore_replica_set: MongoDB) -> MongoDBUser: + """Creates a password secret and then the user referencing it""" + resource = MongoDBUser.from_yaml( + yaml_fixture("scram-sha-user-backing-db.yaml"), namespace=namespace + ) + resource["spec"]["mongodbResourceRef"]["name"] = blockstore_replica_set.name + + print( + f"\nCreating password for MongoDBUser {resource.name} in secret/{resource.get_secret_name()} " + ) + KubernetesTester.create_secret( + KubernetesTester.get_namespace(), + resource.get_secret_name(), + { + "password": USER_PASSWORD, + }, + ) + + yield resource.create() + + +@fixture(scope="module") +def oplog_user(namespace, oplog_replica_set: MongoDB) -> MongoDBUser: + """Creates a password secret and then the user referencing it""" + resource = MongoDBUser.from_yaml( + yaml_fixture("scram-sha-user-backing-db.yaml"), + namespace=namespace, + name="mms-user-2", + ) + resource["spec"]["mongodbResourceRef"]["name"] = oplog_replica_set.name + resource["spec"]["passwordSecretKeyRef"]["name"] = "mms-user-2-password" + resource["spec"]["username"] = "mms-user-2" + + print( + f"\nCreating password for MongoDBUser {resource.name} in secret/{resource.get_secret_name()} " + ) + KubernetesTester.create_secret( + KubernetesTester.get_namespace(), + resource.get_secret_name(), + { + "password": USER_PASSWORD, + }, + ) + + yield resource.create() + + +@mark.e2e_om_ops_manager_backup_manual +class TestOpsManagerCreation: + """ + name: Ops Manager successful creation with backup and oplog stores enabled + description: | + Creates an Ops Manager instance with backup enabled. The OM is expected to get to 'Pending' state + eventually as it will wait for oplog db to be created + """ + + def test_setup_gp2_storage_class(self): + """This is necessary for Backup HeadDB""" + KubernetesTester.make_default_gp2_storage_class() + + def test_create_om(self, ops_manager: MongoDBOpsManager): + """creates a s3 bucket, s3 config and an OM resource (waits until Backup gets to Pending state)""" + ops_manager.backup_status().assert_reaches_phase( + Phase.Pending, + msg_regexp="The MongoDB object .+ doesn't exist", + timeout=900, + ) + + def test_daemon_statefulset(self, ops_manager: MongoDBOpsManager): + def stateful_set_becomes_ready(): + stateful_set = ops_manager.read_backup_statefulset() + return ( + stateful_set.status.ready_replicas == 1 + and stateful_set.status.current_replicas == 1 + ) + + KubernetesTester.wait_until(stateful_set_becomes_ready, timeout=300) + + stateful_set = ops_manager.read_backup_statefulset() + # pod template has volume mount request + assert (HEAD_PATH, "head") in ( + (mount.mount_path, mount.name) + for mount in stateful_set.spec.template.spec.containers[0].volume_mounts + ) + + def test_om(self, ops_manager: MongoDBOpsManager): + om_tester = ops_manager.get_om_tester() + om_tester.assert_healthiness() + for pod_fqdn in ops_manager.backup_daemon_pods_fqdns(): + om_tester.assert_daemon_enabled(pod_fqdn, HEAD_PATH) + + # No oplog stores were created in Ops Manager by this time + om_tester.assert_oplog_stores([]) + om_tester.assert_s3_stores([]) + + +@mark.e2e_om_ops_manager_backup_manual +class TestBackupDatabasesAdded: + def test_backup_mdbs_created( + self, + oplog_replica_set: MongoDB, + s3_replica_set: MongoDB, + blockstore_replica_set: MongoDB, + ): + """Creates mongodb databases all at once""" + oplog_replica_set.assert_reaches_phase(Phase.Running) + s3_replica_set.assert_reaches_phase(Phase.Running) + blockstore_replica_set.assert_reaches_phase(Phase.Running) + + def test_oplog_user_created(self, oplog_user: MongoDBUser): + oplog_user.assert_reaches_phase(Phase.Updated) + + def test_fix_om(self, ops_manager: MongoDBOpsManager, oplog_user: MongoDBUser): + ops_manager.load() + ops_manager["spec"]["backup"]["opLogStores"][0]["mongodbUserRef"] = { + "name": oplog_user.name + } + ops_manager.update() + + ops_manager.backup_status().assert_reaches_phase( + Phase.Running, + timeout=200, + ignore_errors=True, + ) + + +@mark.e2e_om_ops_manager_backup_manual +class TestOpsManagerWatchesBlockStoreUpdates: + def test_om_running(self, ops_manager: MongoDBOpsManager): + ops_manager.backup_status().assert_reaches_phase(Phase.Running, timeout=40) + + def test_scramsha_enabled_for_blockstore(self, blockstore_replica_set: MongoDB): + """Enables SCRAM for the blockstore replica set. Note that until CLOUDP-67736 is fixed + the order of operations (scram first, MongoDBUser - next) is important""" + blockstore_replica_set["spec"]["security"] = { + "authentication": {"enabled": True, "modes": ["SCRAM"]} + } + blockstore_replica_set.update() + blockstore_replica_set.assert_reaches_phase(Phase.Running) + + def test_blockstore_user_was_added_to_om(self, blockstore_user: MongoDBUser): + blockstore_user.assert_reaches_phase(Phase.Updated) + + def test_configure_blockstore_user( + self, ops_manager: MongoDBOpsManager, blockstore_user: MongoDBUser + ): + ops_manager.reload() + ops_manager["spec"]["backup"]["blockStores"][0]["mongodbUserRef"] = { + "name": blockstore_user.name + } + ops_manager.update() + ops_manager.backup_status().assert_reaches_phase( + Phase.Running, + timeout=200, + ignore_errors=True, + ) + assert ops_manager.backup_status().get_message() is None + + +@mark.e2e_om_ops_manager_backup_manual +class TestBackupForMongodb: + @fixture(scope="class") + def mdb_latest( + self, ops_manager: MongoDBOpsManager, namespace, custom_mdb_version: str + ): + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name="rs-fixed", + ).configure(ops_manager, "firstProject") + resource["spec"]["version"] = ensure_ent_version(custom_mdb_version) + + resource["spec"]["podSpec"] = {"podTemplate": {"spec": {}}} + resource["spec"]["podSpec"]["podTemplate"]["spec"]["containers"] = [ + { + "name": "mongodb-enterprise-database", + "resources": { + "requests": { + "memory": "6Gi", + "cpu": "1", + } + }, + } + ] + resource["spec"]["podSpec"]["podTemplate"]["spec"]["initContainers"] = [ + { + "name": "mongodb-enterprise-init-database", + "image": "268558157000.dkr.ecr.us-east-1.amazonaws.com/rodrigo/ubuntu/mongodb-enterprise-init-database:latest-fixed-probe", + } + ] + + resource.configure_backup(mode="disabled") + return resource.create() + + @fixture(scope="class") + def mdb_non_fixed( + self, ops_manager: MongoDBOpsManager, namespace, custom_mdb_version: str + ): + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name="rs-not-fixed", + ).configure(ops_manager, "secondProject") + resource["spec"]["version"] = ensure_ent_version(custom_mdb_version) + + resource["spec"]["podSpec"] = {"podTemplate": {"spec": {}}} + resource["spec"]["podSpec"]["podTemplate"]["spec"]["containers"] = [ + { + "name": "mongodb-enterprise-database", + "resources": { + "requests": { + "memory": "6Gi", + "cpu": "1", + } + }, + } + ] + + resource["spec"]["podSpec"]["podTemplate"]["spec"]["initContainers"] = [ + { + "name": "mongodb-enterprise-init-database", + "image": "268558157000.dkr.ecr.us-east-1.amazonaws.com/rodrigo/ubuntu/mongodb-enterprise-init-database:non-fixed-probe", + } + ] + + resource.configure_backup(mode="disabled") + return resource.create() + + def test_mdbs_created(self, mdb_latest: MongoDB, mdb_non_fixed: MongoDB): + mdb_latest.assert_reaches_phase(Phase.Running) + mdb_non_fixed.assert_reaches_phase(Phase.Running) + + def test_mdbs_enable_backup(self, mdb_latest: MongoDB, mdb_non_fixed: MongoDB): + mdb_latest.load() + mdb_latest.configure_backup(mode="enabled") + mdb_latest.update() + mdb_latest.assert_reaches_phase(Phase.Running) + + mdb_non_fixed.load() + mdb_non_fixed.configure_backup(mode="enabled") + mdb_non_fixed.update() + mdb_non_fixed.assert_reaches_phase(Phase.Running) diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_restore.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_restore.py new file mode 100644 index 000000000..c8f9a9a92 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_restore.py @@ -0,0 +1,262 @@ +import datetime +import time +from typing import Optional + +from kubetester import MongoDB, create_or_update +from kubetester.awss3client import AwsS3Client +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.mongodb import Phase +from kubetester.omtester import OMTester +from kubetester.opsmanager import MongoDBOpsManager +from pymongo.errors import ServerSelectionTimeoutError +from pytest import fixture, mark +from tests.opsmanager.conftest import ensure_ent_version +from tests.opsmanager.om_ops_manager_backup import ( + S3_SECRET_NAME, + create_aws_secret, + create_s3_bucket, +) + +""" +The test checks the backup for MongoDB 4.0 and 4.2, checks that snapshots are built and PIT restore and +restore from snapshot are working. +""" + +TEST_DATA = {"name": "John", "address": "Highway 37", "age": 30} + +OPLOG_SECRET_NAME = S3_SECRET_NAME + "-oplog" + + +@fixture(scope="module") +def s3_bucket(aws_s3_client: AwsS3Client, namespace: str) -> str: + create_aws_secret(aws_s3_client, S3_SECRET_NAME, namespace) + yield from create_s3_bucket(aws_s3_client) + + +@fixture(scope="module") +def oplog_s3_bucket(aws_s3_client: AwsS3Client, namespace: str) -> str: + create_aws_secret(aws_s3_client, OPLOG_SECRET_NAME, namespace) + yield from create_s3_bucket(aws_s3_client) + + +@fixture(scope="module") +def ops_manager( + namespace: str, + s3_bucket: str, + custom_version: Optional[str], + custom_appdb_version: str, +) -> MongoDBOpsManager: + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_backup_light.yaml"), namespace=namespace + ) + resource.set_version(custom_version) + resource.set_appdb_version(custom_appdb_version) + resource.allow_mdb_rc_versions() + resource["spec"]["backup"]["s3Stores"][0]["s3BucketName"] = s3_bucket + + return create_or_update(resource) + + +@fixture(scope="module") +def mdb_latest(ops_manager: MongoDBOpsManager, namespace, custom_mdb_version: str): + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name="mdb-latest", + ).configure(ops_manager, "mdbLatestProject") + # MongoD versions greater than 4.2.0 must be enterprise build to enable backup + resource["spec"]["version"] = ensure_ent_version(custom_mdb_version) + resource.configure_backup(mode="enabled") + return resource.create() + + +@fixture(scope="module") +def mdb_prev(ops_manager: MongoDBOpsManager, namespace, custom_mdb_prev_version: str): + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name="mdb-previous", + ).configure(ops_manager, "mdbPreviousProject") + resource["spec"]["version"] = ensure_ent_version(custom_mdb_prev_version) + resource.configure_backup(mode="enabled") + return resource.create() + + +@fixture(scope="module") +def mdb_prev_test_collection(mdb_prev): + collection = mdb_prev.tester().client["testdb"] + return collection["testcollection"] + + +@fixture(scope="module") +def mdb_latest_test_collection(mdb_latest): + collection = mdb_latest.tester().client["testdb"] + return collection["testcollection"] + + +@fixture(scope="module") +def mdb_prev_project(ops_manager: MongoDBOpsManager) -> OMTester: + return ops_manager.get_om_tester(project_name="mdbPreviousProject") + + +@fixture(scope="module") +def mdb_latest_project(ops_manager: MongoDBOpsManager) -> OMTester: + return ops_manager.get_om_tester(project_name="mdbLatestProject") + + +@mark.e2e_om_ops_manager_backup_restore +class TestOpsManagerCreation: + def test_create_om(self, ops_manager: MongoDBOpsManager): + """creates a s3 bucket and an OM resource, the S3 configs get created using AppDB. Oplog store is still required.""" + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=900) + ops_manager.backup_status().assert_reaches_phase( + Phase.Pending, + msg_regexp="Oplog Store configuration is required for backup", + timeout=300, + ) + + def test_s3_oplog_created(self, ops_manager: MongoDBOpsManager, oplog_s3_bucket: str): + ops_manager.load() + + ops_manager["spec"]["backup"]["s3OpLogStores"] = [ + { + "name": "s3Store2", + "s3SecretRef": { + "name": OPLOG_SECRET_NAME, + }, + "pathStyleAccessEnabled": True, + "s3BucketEndpoint": "s3.us-east-1.amazonaws.com", + "s3BucketName": oplog_s3_bucket, + } + ] + + ops_manager.update() + ops_manager.backup_status().assert_reaches_phase( + Phase.Running, + timeout=500, + ignore_errors=True, + ) + + +@mark.e2e_om_ops_manager_backup_restore +class TestBackupForMongodb: + """This part ensures that backup for the client works correctly and the snapshot is created. + Both Mdb 4.0 and 4.2 are tested (as the backup process for them differs significantly)""" + + def test_mdbs_created(self, mdb_latest: MongoDB, mdb_prev: MongoDB): + mdb_latest.assert_reaches_phase(Phase.Running) + mdb_prev.assert_reaches_phase(Phase.Running) + + def test_add_test_data(self, mdb_prev_test_collection, mdb_latest_test_collection): + mdb_prev_test_collection.insert_one(TEST_DATA) + mdb_latest_test_collection.insert_one(TEST_DATA) + + def test_mdbs_backed_up(self, mdb_prev_project: OMTester, mdb_latest_project: OMTester): + # wait until a first snapshot is ready for both + mdb_prev_project.wait_until_backup_snapshots_are_ready(expected_count=1) + mdb_latest_project.wait_until_backup_snapshots_are_ready(expected_count=1) + + +@mark.e2e_om_ops_manager_backup_restore +class TestBackupRestorePIT: + """This part checks the work of PIT restore.""" + + def test_mdbs_change_data(self, mdb_prev_test_collection, mdb_latest_test_collection): + """Changes the MDB documents to check that restore rollbacks this change later. + Note, that we need to wait for some time to ensure the PIT timestamp gets to the range + [snapshot_created <= PIT <= changes_applied]""" + now_millis = time_to_millis(datetime.datetime.now()) + print("\nCurrent time (millis): {}".format(now_millis)) + time.sleep(30) + + mdb_prev_test_collection.insert_one({"foo": "bar"}) + mdb_latest_test_collection.insert_one({"foo": "bar"}) + + def test_mdbs_pit_restore(self, mdb_prev_project: OMTester, mdb_latest_project: OMTester): + now_millis = time_to_millis(datetime.datetime.now()) + print("\nCurrent time (millis): {}".format(now_millis)) + + pit_datetme = datetime.datetime.now() - datetime.timedelta(seconds=15) + pit_millis = time_to_millis(pit_datetme) + print("Restoring back to the moment 15 seconds ago (millis): {}".format(pit_millis)) + + mdb_prev_project.create_restore_job_pit(pit_millis) + mdb_latest_project.create_restore_job_pit(pit_millis) + + # Note, that we are not waiting for the restore jobs to get finished as PIT restore jobs get FINISHED status + # right away + + def test_data_got_restored(self, mdb_prev_test_collection, mdb_latest_test_collection): + """The data in the db has been restored to the initial state. Note, that this happens eventually - so + we need to loop for some time (usually takes 20 seconds max). This is different from restoring from a + specific snapshot (see the previous class) where the FINISHED restore job means the data has been restored. + For PIT restores FINISHED just means the job has been created and the agents will perform restore eventually""" + print("\nWaiting until the db data is restored") + retries = 120 + while retries > 0: + try: + records = list(mdb_prev_test_collection.find()) + assert records == [TEST_DATA] + + records = list(mdb_latest_test_collection.find()) + assert records == [TEST_DATA] + return + except AssertionError: + pass + except ServerSelectionTimeoutError: + # The mongodb driver complains with `No replica set members + # match selector "Primary()",` This could be related with DNS + # not being functional, or the database going through a + # re-election process. Let's give it another chance to succeed. + pass + except Exception as e: + # We ignore Exception as there is usually a blip in connection (backup restore + # results in reelection or whatever) + # "Connection reset by peer" or "not master and slaveOk=false" + print("Exception happened while waiting for db data restore: ", e) + # this is definitely the sign of a problem - no need continuing as each connection times out + # after many minutes + if "Connection refused" in str(e): + raise e + retries -= 1 + time.sleep(1) + + print("\nExisting data in previous MDB: {}".format(list(mdb_prev_test_collection.find()))) + print("Existing data in latest MDB: {}".format(list(mdb_latest_test_collection.find()))) + + raise AssertionError("The data hasn't been restored in 2 minutes!") + + +@mark.e2e_om_ops_manager_backup_restore +class TestBackupRestoreFromSnapshot: + """This part tests the restore to the snapshot built once the backup has been enabled.""" + + def test_mdbs_change_data(self, mdb_prev_test_collection, mdb_latest_test_collection): + """Changes the MDB documents to check that restore rollbacks this change later""" + mdb_prev_test_collection.delete_many({}) + mdb_prev_test_collection.insert_one({"foo": "bar"}) + + mdb_latest_test_collection.delete_many({}) + mdb_latest_test_collection.insert_one({"foo": "bar"}) + + def test_mdbs_automated_restore(self, mdb_prev_project: OMTester, mdb_latest_project: OMTester): + restore_prev_id = mdb_prev_project.create_restore_job_snapshot() + mdb_prev_project.wait_until_restore_job_is_ready(restore_prev_id) + + restore_latest_id = mdb_latest_project.create_restore_job_snapshot() + mdb_latest_project.wait_until_restore_job_is_ready(restore_latest_id) + + def test_data_got_restored(self, mdb_prev_test_collection, mdb_latest_test_collection): + """The data in the db has been restored to the initial""" + records = list(mdb_prev_test_collection.find()) + assert records == [TEST_DATA] + + records = list(mdb_latest_test_collection.find()) + assert records == [TEST_DATA] + + +def time_to_millis(date_time) -> int: + """https://stackoverflow.com/a/11111177/614239""" + epoch = datetime.datetime.utcfromtimestamp(0) + pit_millis = (date_time - epoch).total_seconds() * 1000 + return pit_millis diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_restore_minio.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_restore_minio.py new file mode 100644 index 000000000..f836db890 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_restore_minio.py @@ -0,0 +1,451 @@ +import datetime +import os +import time +from typing import Optional, List + +from kubetester import MongoDB +from kubetester.certs import create_ops_manager_tls_certs, create_mongodb_tls_certs +from kubetester.kubetester import fixture as yaml_fixture, KubernetesTester +from kubetester.mongodb import Phase +from kubetester.mongotester import with_tls +from kubetester.omtester import OMTester +from kubetester.opsmanager import MongoDBOpsManager +from pymongo.errors import ServerSelectionTimeoutError +from pytest import fixture, mark + +from tests.opsmanager.conftest import ensure_ent_version +from tests.opsmanager.om_ops_manager_backup import ( + S3_SECRET_NAME, +) +from tests.opsmanager.om_ops_manager_backup_tls_custom_ca import ( + FIRST_PROJECT_RS_NAME, + SECOND_PROJECT_RS_NAME, +) + +""" +Please read ops-manager-kubernetes/docker/mongodb-enterprise-tests/tests/docs/MINIO.md for instructions on how +to run this test. It is not yet automated. +""" + +TEST_DATA = {"name": "John", "address": "Highway 37", "age": 30} +PLACEHOLDER = "REPLACE ME" +OPLOG_SECRET_NAME = S3_SECRET_NAME + "-oplog" + + +@fixture(scope="module") +def appdb_certs(namespace: str, issuer: str) -> str: + create_mongodb_tls_certs( + issuer, namespace, "om-backup-db", "appdb-om-backup-db-cert" + ) + return "appdb" + + +@fixture(scope="module") +def ops_manager_certs(namespace: str, issuer: str, tenant_domains: List[str]): + return create_ops_manager_tls_certs( + issuer, namespace, "om-backup", additional_domains=tenant_domains + ) + + +@fixture(scope="module") +def first_project_certs(namespace: str, issuer: str): + create_mongodb_tls_certs( + issuer, + namespace, + FIRST_PROJECT_RS_NAME, + f"first-project-{FIRST_PROJECT_RS_NAME}-cert", + ) + return "first-project" + + +@fixture(scope="module") +def second_project_certs(namespace: str, issuer: str): + create_mongodb_tls_certs( + issuer, + namespace, + SECOND_PROJECT_RS_NAME, + f"second-project-{SECOND_PROJECT_RS_NAME}-cert", + ) + return "second-project" + + +@fixture(scope="module") +def tenant_name() -> str: + return "tenant-0" + + +@fixture(scope="module") +def tenant_domains() -> List[str]: + return [ + "tenant-0-pool-0-{0...3}.tenant-0-hl.tenant-0.svc.cluster.local", + "minio.tenant-0.svc.cluster.local", + "minio.tenant-0", + "minio.tenant-0.svc", + ".tenant-0-hl.tenant-0.svc.cluster.local", + ".tenant-0.svc.cluster.local", + ] + + +@fixture(scope="module") +def s3_bucket_endpoint(tenant_name: str) -> str: + return f"minio.{tenant_name}.svc.cluster.local" + + +@fixture(scope="module") +def oplog_s3_bucket_name() -> str: + return "oplog-s3-bucket" + + +@fixture(scope="module") +def s3_store_bucket_name() -> str: + return "s3-store-bucket" + + +@fixture(scope="module") +def minio_s3_access_key() -> str: + return PLACEHOLDER + + +@fixture(scope="module") +def minio_s3_secret_key() -> str: + return PLACEHOLDER + + +@fixture(scope="module") +def ops_manager( + namespace: str, + issuer_ca_configmap: str, + custom_version: Optional[str], + custom_appdb_version: str, + ops_manager_certs: str, + appdb_certs: str, + s3_bucket_endpoint: str, + s3_store_bucket_name: str, + minio_s3_access_key: str, + minio_s3_secret_key: str, + issuer_ca_filepath: str, +) -> MongoDBOpsManager: + + if minio_s3_secret_key == PLACEHOLDER or minio_s3_access_key == PLACEHOLDER: + raise ValueError( + "You must manually update the fixtures minio_s3_secret_key and minio_s3_access_key to return real values!" + ) + + # ensure the requests library will use this CA when communicating with Ops Manager + os.environ["REQUESTS_CA_BUNDLE"] = issuer_ca_filepath + + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_backup_light.yaml"), namespace=namespace + ) + + # these values come from the tenant creation in minio. + KubernetesTester.create_secret( + namespace, + S3_SECRET_NAME, + { + "accessKey": minio_s3_access_key, + "secretKey": minio_s3_secret_key, + }, + ) + + KubernetesTester.create_secret( + namespace, + OPLOG_SECRET_NAME, + { + "accessKey": minio_s3_access_key, + "secretKey": minio_s3_secret_key, + }, + ) + + resource.set_version(custom_version) + resource.set_appdb_version(custom_appdb_version) + resource.allow_mdb_rc_versions() + resource["spec"]["configuration"]["mms.preflight.run"] = "false" + resource["spec"]["backup"]["s3Stores"][0]["s3BucketName"] = s3_store_bucket_name + resource["spec"]["backup"]["s3Stores"][0]["s3BucketEndpoint"] = s3_bucket_endpoint + resource["spec"]["security"] = { + "tls": {"ca": issuer_ca_configmap, "secretRef": {"name": ops_manager_certs}} + } + resource["spec"]["applicationDatabase"]["security"] = { + "tls": {"ca": issuer_ca_configmap, "secretRef": {"prefix": appdb_certs}} + } + return resource.create() + + +@fixture(scope="module") +def mdb_latest( + ops_manager: MongoDBOpsManager, + namespace, + custom_mdb_version: str, + issuer_ca_configmap: str, + first_project_certs: str, +): + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=FIRST_PROJECT_RS_NAME, + ).configure(ops_manager, "mdbLatestProject") + # MongoD versions greater than 4.2.0 must be enterprise build to enable backup + resource["spec"]["version"] = ensure_ent_version(custom_mdb_version) + resource.configure_backup(mode="enabled") + resource.configure_custom_tls(issuer_ca_configmap, first_project_certs) + return resource.create() + + +@fixture(scope="module") +def mdb_prev( + ops_manager: MongoDBOpsManager, + namespace, + custom_mdb_prev_version: str, + issuer_ca_configmap: str, + second_project_certs: str, +): + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=SECOND_PROJECT_RS_NAME, + ).configure(ops_manager, "mdbPreviousProject") + resource["spec"]["version"] = ensure_ent_version(custom_mdb_prev_version) + resource.configure_backup(mode="enabled") + resource.configure_custom_tls(issuer_ca_configmap, second_project_certs) + return resource.create() + + +@fixture(scope="module") +def mdb_prev_test_collection(mdb_prev, ca_path: str): + tester = mdb_prev.tester() + tester.assert_connectivity(opts=[with_tls(use_tls=True, ca_path=ca_path)]) + + collection = tester.client["testdb"] + return collection["testcollection"] + + +@fixture(scope="module") +def mdb_latest_test_collection(mdb_latest, ca_path: str): + tester = mdb_latest.tester() + tester.assert_connectivity(opts=[with_tls(use_tls=True, ca_path=ca_path)]) + + collection = tester.client["testdb"] + return collection["testcollection"] + + +@fixture(scope="module") +def mdb_prev_project(ops_manager: MongoDBOpsManager) -> OMTester: + return ops_manager.get_om_tester(project_name="mdbPreviousProject") + + +@fixture(scope="module") +def mdb_latest_project(ops_manager: MongoDBOpsManager) -> OMTester: + return ops_manager.get_om_tester(project_name="mdbLatestProject") + + +@mark.e2e_om_ops_manager_backup_restore_minio +class TestOpsManagerCreation: + def test_create_om(self, ops_manager: MongoDBOpsManager): + """creates a s3 bucket and an OM resource, the S3 configs get created using AppDB. Oplog store is still required.""" + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=900) + ops_manager.backup_status().assert_reaches_phase( + Phase.Pending, + msg_regexp="Oplog Store configuration is required for backup", + timeout=300, + ) + + def test_s3_oplog_created( + self, + ops_manager: MongoDBOpsManager, + oplog_s3_bucket_name: str, + s3_bucket_endpoint: str, + ): + ops_manager.load() + + ops_manager["spec"]["backup"]["s3OpLogStores"] = [ + { + "name": "s3Store2", + "s3SecretRef": { + "name": OPLOG_SECRET_NAME, + }, + "pathStyleAccessEnabled": True, + "s3BucketEndpoint": s3_bucket_endpoint, + "s3BucketName": oplog_s3_bucket_name, + } + ] + + ops_manager.update() + ops_manager.backup_status().assert_reaches_phase( + Phase.Running, + timeout=500, + ignore_errors=True, + ) + + def test_add_custom_ca_to_project_configmap( + self, ops_manager: MongoDBOpsManager, issuer_ca_plus: str, namespace: str + ): + projects = [ + ops_manager.get_or_create_mongodb_connection_config_map( + FIRST_PROJECT_RS_NAME, "firstProject" + ), + ops_manager.get_or_create_mongodb_connection_config_map( + SECOND_PROJECT_RS_NAME, "secondProject" + ), + ] + + data = { + "sslMMSCAConfigMap": issuer_ca_plus, + } + + for project in projects: + KubernetesTester.update_configmap(namespace, project, data) + + # Give a few seconds for the operator to catch the changes on + # the project ConfigMaps + time.sleep(10) + + +@mark.e2e_om_ops_manager_backup_restore_minio +class TestBackupForMongodb: + """This part ensures that backup for the client works correctly and the snapshot is created. + Both Mdb 4.0 and 4.2 are tested (as the backup process for them differs significantly)""" + + def test_mdbs_created(self, mdb_latest: MongoDB, mdb_prev: MongoDB): + mdb_latest.assert_reaches_phase(Phase.Running) + mdb_prev.assert_reaches_phase(Phase.Running) + + def test_add_test_data(self, mdb_prev_test_collection, mdb_latest_test_collection): + mdb_prev_test_collection.insert_one(TEST_DATA) + mdb_latest_test_collection.insert_one(TEST_DATA) + + def test_mdbs_backed_up( + self, mdb_prev_project: OMTester, mdb_latest_project: OMTester + ): + # wait until a first snapshot is ready for both + mdb_prev_project.wait_until_backup_snapshots_are_ready(expected_count=1) + mdb_latest_project.wait_until_backup_snapshots_are_ready(expected_count=1) + + +@mark.e2e_om_ops_manager_backup_restore_minio +class TestBackupRestorePIT: + """This part checks the work of PIT restore.""" + + def test_mdbs_change_data( + self, mdb_prev_test_collection, mdb_latest_test_collection + ): + """Changes the MDB documents to check that restore rollbacks this change later. + Note, that we need to wait for some time to ensure the PIT timestamp gets to the range + [snapshot_created <= PIT <= changes_applied]""" + now_millis = time_to_millis(datetime.datetime.now()) + print("\nCurrent time (millis): {}".format(now_millis)) + time.sleep(30) + + mdb_prev_test_collection.insert_one({"foo": "bar"}) + mdb_latest_test_collection.insert_one({"foo": "bar"}) + + def test_mdbs_pit_restore( + self, mdb_prev_project: OMTester, mdb_latest_project: OMTester + ): + now_millis = time_to_millis(datetime.datetime.now()) + print("\nCurrent time (millis): {}".format(now_millis)) + + pit_datetme = datetime.datetime.now() - datetime.timedelta(seconds=15) + pit_millis = time_to_millis(pit_datetme) + print( + "Restoring back to the moment 15 seconds ago (millis): {}".format( + pit_millis + ) + ) + + mdb_prev_project.create_restore_job_pit(pit_millis) + mdb_latest_project.create_restore_job_pit(pit_millis) + + # Note, that we are not waiting for the restore jobs to get finished as PIT restore jobs get FINISHED status + # right away + + def test_data_got_restored( + self, mdb_prev_test_collection, mdb_latest_test_collection + ): + """The data in the db has been restored to the initial state. Note, that this happens eventually - so + we need to loop for some time (usually takes 20 seconds max). This is different from restoring from a + specific snapshot (see the previous class) where the FINISHED restore job means the data has been restored. + For PIT restores FINISHED just means the job has been created and the agents will perform restore eventually""" + print("\nWaiting until the db data is restored") + retries = 120 + while retries > 0: + try: + records = list(mdb_prev_test_collection.find()) + assert records == [TEST_DATA] + + records = list(mdb_latest_test_collection.find()) + assert records == [TEST_DATA] + return + except AssertionError: + pass + except ServerSelectionTimeoutError: + # The mongodb driver complains with `No replica set members + # match selector "Primary()",` This could be related with DNS + # not being functional, or the database going through a + # re-election process. Let's give it another chance to succeed. + pass + except Exception as e: + # We ignore Exception as there is usually a blip in connection (backup restore + # results in reelection or whatever) + # "Connection reset by peer" or "not master and slaveOk=false" + print("Exception happened while waiting for db data restore: ", e) + # this is definitely the sign of a problem - no need continuing as each connection times out + # after many minutes + if "Connection refused" in str(e): + raise e + retries -= 1 + time.sleep(1) + + print( + "\nExisting data in previous MDB: {}".format( + list(mdb_prev_test_collection.find()) + ) + ) + print( + "Existing data in latest MDB: {}".format( + list(mdb_latest_test_collection.find()) + ) + ) + + raise AssertionError("The data hasn't been restored in 2 minutes!") + + +@mark.e2e_om_ops_manager_backup_restore_minio +class TestBackupRestoreFromSnapshot: + """This part tests the restore to the snapshot built once the backup has been enabled.""" + + def test_mdbs_change_data( + self, mdb_prev_test_collection, mdb_latest_test_collection + ): + """Changes the MDB documents to check that restore rollbacks this change later""" + mdb_prev_test_collection.delete_many({}) + mdb_prev_test_collection.insert_one({"foo": "bar"}) + + mdb_latest_test_collection.delete_many({}) + mdb_latest_test_collection.insert_one({"foo": "bar"}) + + def test_mdbs_automated_restore( + self, mdb_prev_project: OMTester, mdb_latest_project: OMTester + ): + restore_prev_id = mdb_prev_project.create_restore_job_snapshot() + mdb_prev_project.wait_until_restore_job_is_ready(restore_prev_id) + + restore_latest_id = mdb_latest_project.create_restore_job_snapshot() + mdb_latest_project.wait_until_restore_job_is_ready(restore_latest_id) + + def test_data_got_restored( + self, mdb_prev_test_collection, mdb_latest_test_collection + ): + """The data in the db has been restored to the initial""" + records = list(mdb_prev_test_collection.find()) + assert records == [TEST_DATA] + + records = list(mdb_latest_test_collection.find()) + assert records == [TEST_DATA] + + +def time_to_millis(date_time) -> int: + """https://stackoverflow.com/a/11111177/614239""" + epoch = datetime.datetime.utcfromtimestamp(0) + pit_millis = (date_time - epoch).total_seconds() * 1000 + return pit_millis diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_s3_tls.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_s3_tls.py new file mode 100644 index 000000000..1eb646c57 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_s3_tls.py @@ -0,0 +1,115 @@ +from typing import Optional, Dict + +from pytest import mark, fixture + +from kubetester import MongoDB +from kubetester.awss3client import AwsS3Client, s3_endpoint +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.mongodb import Phase +from kubetester.opsmanager import MongoDBOpsManager +from tests.opsmanager.conftest import ensure_ent_version +from tests.opsmanager.om_ops_manager_backup import ( + AWS_REGION, + create_aws_secret, + create_s3_bucket, +) +from tests.opsmanager.om_ops_manager_https import create_mongodb_tls_certs + +""" +This test checks the work with TLS-enabled backing databases (oplog & blockstore) +""" + +S3_OPLOG_NAME = "s3-oplog" +S3_BLOCKSTORE_NAME = "s3-blockstore" + + +@fixture(scope="module") +def appdb_certs_secret(namespace: str, issuer: str): + create_mongodb_tls_certs( + issuer, namespace, "om-backup-tls-s3-db", "appdb-om-backup-tls-s3-db-cert" + ) + return "appdb" + + +@fixture(scope="module") +def s3_bucket_oplog(aws_s3_client: AwsS3Client, namespace: str) -> str: + create_aws_secret(aws_s3_client, S3_OPLOG_NAME + "-secret", namespace) + yield from create_s3_bucket(aws_s3_client) + + +@fixture(scope="module") +def s3_bucket_blockstore(aws_s3_client: AwsS3Client, namespace: str) -> str: + create_aws_secret(aws_s3_client, S3_BLOCKSTORE_NAME + "-secret", namespace) + yield from create_s3_bucket(aws_s3_client) + + +@fixture(scope="module") +def ops_manager( + namespace, + issuer_ca_configmap: str, + appdb_certs_secret: str, + custom_version: Optional[str], + custom_appdb_version: str, + s3_bucket_oplog: str, + s3_bucket_blockstore: str, +) -> MongoDBOpsManager: + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_backup_tls_s3.yaml"), namespace=namespace + ) + resource.set_version(custom_version) + resource.set_appdb_version(custom_appdb_version) + resource.allow_mdb_rc_versions() + + resource["spec"]["backup"]["s3Stores"][0]["name"] = S3_BLOCKSTORE_NAME + resource["spec"]["backup"]["s3Stores"][0]["s3SecretRef"]["name"] = ( + S3_BLOCKSTORE_NAME + "-secret" + ) + resource["spec"]["backup"]["s3Stores"][0]["s3BucketEndpoint"] = s3_endpoint( + AWS_REGION + ) + resource["spec"]["backup"]["s3Stores"][0]["s3BucketName"] = s3_bucket_blockstore + resource["spec"]["backup"]["s3Stores"][0]["s3RegionOverride"] = AWS_REGION + resource["spec"]["backup"]["s3OpLogStores"][0]["name"] = S3_OPLOG_NAME + resource["spec"]["backup"]["s3OpLogStores"][0]["s3SecretRef"]["name"] = ( + S3_OPLOG_NAME + "-secret" + ) + resource["spec"]["backup"]["s3OpLogStores"][0]["s3BucketEndpoint"] = s3_endpoint( + AWS_REGION + ) + resource["spec"]["backup"]["s3OpLogStores"][0]["s3BucketName"] = s3_bucket_oplog + resource["spec"]["backup"]["s3OpLogStores"][0]["s3RegionOverride"] = AWS_REGION + return resource.create() + + +@mark.e2e_om_ops_manager_backup_s3_tls +class TestOpsManagerCreation: + def test_create_om(self, ops_manager: MongoDBOpsManager): + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=600) + + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=600) + + # appdb rolling restart for configuring monitoring + ops_manager.appdb_status().assert_abandons_phase(Phase.Running, timeout=200) + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=400) + + def test_om_is_running( + self, + ops_manager: MongoDBOpsManager, + ): + ops_manager.backup_status().assert_reaches_phase( + Phase.Running, timeout=600, ignore_errors=True + ) + om_tester = ops_manager.get_om_tester() + om_tester.assert_healthiness() + + def test_om_s3_stores( + self, + ops_manager: MongoDBOpsManager, + ): + om_tester = ops_manager.get_om_tester() + om_tester.assert_s3_stores( + [{"id": S3_BLOCKSTORE_NAME, "s3RegionOverride": AWS_REGION}] + ) + om_tester.assert_oplog_s3_stores( + [{"id": S3_OPLOG_NAME, "s3RegionOverride": AWS_REGION}] + ) diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_sharded_cluster.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_sharded_cluster.py new file mode 100644 index 000000000..cb076ee5b --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_sharded_cluster.py @@ -0,0 +1,345 @@ +from typing import Optional + +import tests.opsmanager.om_ops_manager_backup_sharded_cluster +from kubetester import get_default_storage_class, try_load, create_or_update +from kubetester.awss3client import AwsS3Client +from kubetester.kubetester import ( + fixture as yaml_fixture, + KubernetesTester, +) +from kubetester.mongodb import Phase, MongoDB +from kubetester.mongodb_user import MongoDBUser +from kubetester.opsmanager import MongoDBOpsManager +from pytest import mark, fixture + +from tests.opsmanager.backup_snapshot_schedule_tests import BackupSnapshotScheduleTests +from tests.opsmanager.conftest import ensure_ent_version +from tests.opsmanager.om_ops_manager_backup import ( + create_aws_secret, + create_s3_bucket, +) + +HEAD_PATH = "/head/" +S3_SECRET_NAME = "my-s3-secret" +AWS_REGION = "us-east-1" +OPLOG_RS_NAME = "my-mongodb-oplog" +S3_RS_NAME = "my-mongodb-s3" +BLOCKSTORE_RS_NAME = "my-mongodb-blockstore" +USER_PASSWORD = "/qwerty@!#:" + + +@fixture(scope="module") +def s3_bucket(aws_s3_client: AwsS3Client, namespace: str) -> str: + create_aws_secret(aws_s3_client, S3_SECRET_NAME, namespace) + yield from create_s3_bucket(aws_s3_client, "test-bucket-sharded-") + + +@fixture(scope="module") +def ops_manager( + namespace: str, + s3_bucket: str, + custom_version: Optional[str], + custom_appdb_version: str, +) -> MongoDBOpsManager: + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_backup.yaml"), namespace=namespace + ) + + try_load(resource) + return resource + + +@fixture(scope="module") +def oplog_replica_set(ops_manager, namespace, custom_mdb_version: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=OPLOG_RS_NAME, + ).configure(ops_manager, "development") + resource["spec"]["version"] = custom_mdb_version + + # TODO: Remove when CLOUDP-60443 is fixed + # This test will update oplog to have SCRAM enabled + # Currently this results in OM failure when enabling backup for a project, backup seems to do some caching resulting in the + # mongoURI not being updated unless pod is killed. This is documented in CLOUDP-60443, once resolved this skip & comment can be deleted + resource["spec"]["security"] = {"authentication": {"enabled": True, "modes": ["SCRAM"]}} + + create_or_update(resource) + return resource + + +@fixture(scope="module") +def s3_replica_set(ops_manager, namespace) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=S3_RS_NAME, + ).configure(ops_manager, "s3metadata") + + create_or_update(resource) + return resource + + +@fixture(scope="module") +def blockstore_replica_set(ops_manager, namespace, custom_mdb_version: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=BLOCKSTORE_RS_NAME, + ).configure(ops_manager, "blockstore") + resource["spec"]["version"] = custom_mdb_version + create_or_update(resource) + return resource + + +@fixture(scope="module") +def blockstore_user(namespace, blockstore_replica_set: MongoDB) -> MongoDBUser: + """Creates a password secret and then the user referencing it""" + resource = MongoDBUser.from_yaml(yaml_fixture("scram-sha-user-backing-db.yaml"), namespace=namespace) + resource["spec"]["mongodbResourceRef"]["name"] = blockstore_replica_set.name + + print(f"\nCreating password for MongoDBUser {resource.name} in secret/{resource.get_secret_name()} ") + KubernetesTester.create_secret( + KubernetesTester.get_namespace(), + resource.get_secret_name(), + { + "password": USER_PASSWORD, + }, + ) + + create_or_update(resource) + return resource + + +@fixture(scope="module") +def oplog_user(namespace, oplog_replica_set: MongoDB) -> MongoDBUser: + """Creates a password secret and then the user referencing it""" + resource = MongoDBUser.from_yaml( + yaml_fixture("scram-sha-user-backing-db.yaml"), + namespace=namespace, + name="mms-user-2", + ) + resource["spec"]["mongodbResourceRef"]["name"] = oplog_replica_set.name + resource["spec"]["passwordSecretKeyRef"]["name"] = "mms-user-2-password" + resource["spec"]["username"] = "mms-user-2" + + print(f"\nCreating password for MongoDBUser {resource.name} in secret/{resource.get_secret_name()} ") + KubernetesTester.create_secret( + KubernetesTester.get_namespace(), + resource.get_secret_name(), + { + "password": USER_PASSWORD, + }, + ) + + yield create_or_update(resource) + + +@mark.e2e_om_ops_manager_backup_sharded_cluster +class TestOpsManagerCreation: + """ + name: Ops Manager successful creation with backup and oplog stores enabled + description: | + Creates an Ops Manager instance with backup enabled. The OM is expected to get to 'Pending' state + eventually as it will wait for oplog db to be created + """ + + def test_setup_gp2_storage_class(self): + """This is necessary for Backup HeadDB""" + KubernetesTester.make_default_gp2_storage_class() + + def test_create_om( + self, + ops_manager: MongoDBOpsManager, + s3_bucket: str, + custom_version: str, + custom_appdb_version: str, + ): + """creates a s3 bucket, s3 config and an OM resource (waits until Backup gets to Pending state)""" + + ops_manager["spec"]["backup"]["s3Stores"][0]["s3BucketName"] = s3_bucket + ops_manager["spec"]["backup"]["headDB"]["storageClass"] = get_default_storage_class() + ops_manager["spec"]["backup"]["members"] = 2 + + ops_manager.set_version(custom_version) + ops_manager.set_appdb_version(custom_appdb_version) + ops_manager.allow_mdb_rc_versions() + + create_or_update(ops_manager) + + ops_manager.backup_status().assert_reaches_phase( + Phase.Pending, + msg_regexp="The MongoDB object .+ doesn't exist", + timeout=900, + ) + + def test_daemon_statefulset(self, ops_manager: MongoDBOpsManager): + def stateful_set_becomes_ready(): + stateful_set = ops_manager.read_backup_statefulset() + return stateful_set.status.ready_replicas == 2 and stateful_set.status.current_replicas == 2 + + KubernetesTester.wait_until(stateful_set_becomes_ready, timeout=300) + + stateful_set = ops_manager.read_backup_statefulset() + # pod template has volume mount request + assert (HEAD_PATH, "head") in ( + (mount.mount_path, mount.name) for mount in stateful_set.spec.template.spec.containers[0].volume_mounts + ) + + +@mark.e2e_om_ops_manager_backup_sharded_cluster +class TestBackupDatabasesAdded: + """name: Creates three mongodb resources for oplog, s3 and blockstore and waits until OM resource gets to + running state""" + + def test_backup_mdbs_created( + self, + oplog_replica_set: MongoDB, + s3_replica_set: MongoDB, + blockstore_replica_set: MongoDB, + ): + """Creates mongodb databases all at once""" + oplog_replica_set.assert_reaches_phase(Phase.Running) + s3_replica_set.assert_reaches_phase(Phase.Running) + blockstore_replica_set.assert_reaches_phase(Phase.Running) + + def test_oplog_user_created(self, oplog_user: MongoDBUser): + oplog_user.assert_reaches_phase(Phase.Updated) + + def test_oplog_updated_scram_sha_enabled(self, oplog_replica_set: MongoDB): + oplog_replica_set.load() + oplog_replica_set["spec"]["security"] = {"authentication": {"enabled": True, "modes": ["SCRAM"]}} + oplog_replica_set.update() + oplog_replica_set.assert_reaches_phase(Phase.Running) + + def test_om_failed_oplog_no_user_ref(self, ops_manager: MongoDBOpsManager): + """Waits until Backup is in failed state as blockstore doesn't have reference to the user""" + ops_manager.backup_status().assert_reaches_phase( + Phase.Failed, + msg_regexp=".*is configured to use SCRAM-SHA authentication mode, the user " + "must be specified using 'mongodbUserRef'", + ) + + def test_fix_om(self, ops_manager: MongoDBOpsManager, oplog_user: MongoDBUser): + ops_manager.load() + ops_manager["spec"]["backup"]["opLogStores"][0]["mongodbUserRef"] = {"name": oplog_user.name} + ops_manager.update() + + ops_manager.backup_status().assert_reaches_phase( + Phase.Running, + timeout=200, + ignore_errors=True, + ) + + assert ops_manager.backup_status().get_message() is None + + +@mark.e2e_om_ops_manager_backup_sharded_cluster +class TestBackupForMongodb: + """This part ensures that backup for the client works correctly and the snapshot is created. + Both latest and the one before the latest are tested (as the backup process for them may differ significantly)""" + + @fixture(scope="class") + def mdb_latest(self, ops_manager: MongoDBOpsManager, namespace, custom_mdb_version: str): + resource = MongoDB.from_yaml( + yaml_fixture("sharded-cluster-for-om.yaml"), + namespace=namespace, + name="mdb-four-two", + ).configure(ops_manager, "firstProject") + resource["spec"]["version"] = ensure_ent_version(custom_mdb_version) + resource.configure_backup(mode="disabled") + create_or_update(resource) + return resource + + @fixture(scope="class") + def mdb_prev(self, ops_manager: MongoDBOpsManager, namespace, custom_mdb_prev_version: str): + resource = MongoDB.from_yaml( + yaml_fixture("sharded-cluster-for-om.yaml"), + namespace=namespace, + name="mdb-four-zero", + ).configure(ops_manager, "secondProject") + resource["spec"]["version"] = ensure_ent_version(custom_mdb_prev_version) + resource.configure_backup(mode="disabled") + create_or_update(resource) + return resource + + def test_mdbs_created(self, mdb_latest: MongoDB, mdb_prev: MongoDB): + mdb_latest.assert_reaches_phase(Phase.Running) + mdb_prev.assert_reaches_phase(Phase.Running) + + def test_mdbs_enable_backup(self, mdb_latest: MongoDB, mdb_prev: MongoDB): + mdb_latest.load() + mdb_latest.configure_backup(mode="enabled") + mdb_latest.update() + mdb_prev.load() + mdb_prev.configure_backup(mode="enabled") + mdb_prev.update() + mdb_prev.assert_reaches_phase(Phase.Running) + mdb_latest.assert_reaches_phase(Phase.Running) + + def test_mdbs_backuped(self, ops_manager: MongoDBOpsManager): + om_tester_first = ops_manager.get_om_tester(project_name="firstProject") + om_tester_second = ops_manager.get_om_tester(project_name="secondProject") + + # wait until a first snapshot is ready for both + om_tester_first.wait_until_backup_snapshots_are_ready(expected_count=1, expected_config_count=4) + om_tester_second.wait_until_backup_snapshots_are_ready(expected_count=1, expected_config_count=4) + + def test_can_transition_from_started_to_stopped(self, mdb_latest: MongoDB, mdb_prev: MongoDB): + # a direction transition from enabled to disabled is a single + # step for the operator + mdb_prev.assert_backup_reaches_status("STARTED", timeout=100) + mdb_prev.configure_backup(mode="disabled") + mdb_prev.update() + mdb_prev.assert_backup_reaches_status("STOPPED", timeout=600) + + def test_can_transition_from_started_to_terminated_0(self, mdb_latest: MongoDB, mdb_prev: MongoDB): + # a direct transition from enabled to terminated is not possible + # the operator should handle the transition from STARTED -> STOPPED -> TERMINATING + mdb_latest.assert_backup_reaches_status("STARTED", timeout=100) + mdb_latest.configure_backup(mode="terminated") + mdb_latest.update() + mdb_latest.assert_backup_reaches_status("TERMINATING", timeout=600) + + +# This test extends om_ops_manager_backup.TestBackupSnapshotSchedule tests but overrides fixtures +# to run snapshot schedule tests on sharded MongoDB with FCV 4.0. +# Additionally, it tests clusterCheckpointInterval field. +@mark.e2e_om_ops_manager_backup_sharded_cluster +class TestBackupSnapshotScheduleOnMongoDBFCV40(BackupSnapshotScheduleTests): + @fixture + def mdb(self, ops_manager: MongoDBOpsManager): + resource = MongoDB.from_yaml( + yaml_fixture("sharded-cluster-for-om.yaml"), + namespace=ops_manager.namespace, + name="mdb-snapshot-sharded-on-fcv-40", + ) + + try_load(resource) + + resource["spec"]["featureCompatibilityVersion"] = "3.6" + + return resource + + @fixture + def om_project_name(self): + return "backupSnapshotScheduleShardedFCV40" + + @fixture + def mdb_version(self): + return "4.0.28" + + def test_cluster_checkpoint_interval(self, mdb: MongoDB): + self.update_snapshot_schedule( + mdb, + { + "clusterCheckpointIntervalMin": 60, + }, + ) + + self.assert_snapshot_schedule_in_ops_manager( + mdb.get_om_tester(), + { + "clusterCheckpointIntervalMin": 60, + }, + ) diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_tls.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_tls.py new file mode 100644 index 000000000..d11774d46 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_tls.py @@ -0,0 +1,187 @@ +from typing import Optional + +from pytest import mark, fixture + +from kubetester import MongoDB, create_or_update +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.mongodb import Phase +from kubetester.opsmanager import MongoDBOpsManager +from tests.opsmanager.conftest import ensure_ent_version +from tests.opsmanager.om_ops_manager_backup import ( + OPLOG_RS_NAME, + BLOCKSTORE_RS_NAME, + new_om_data_store, +) +from tests.opsmanager.om_ops_manager_https import create_mongodb_tls_certs + +""" +This test checks the work with TLS-enabled backing databases (oplog & blockstore) +""" + + +@fixture(scope="module") +def appdb_certs_secret(namespace: str, issuer: str): + create_mongodb_tls_certs( + issuer, namespace, "om-backup-tls-db", "appdb-om-backup-tls-db-cert" + ) + return "appdb" + + +@fixture(scope="module") +def oplog_certs_secret(namespace: str, issuer: str): + create_mongodb_tls_certs( + issuer, namespace, OPLOG_RS_NAME, f"oplog-{OPLOG_RS_NAME}-cert" + ) + return "oplog" + + +@fixture(scope="module") +def blockstore_certs_secret(namespace: str, issuer: str): + create_mongodb_tls_certs( + issuer, namespace, BLOCKSTORE_RS_NAME, f"blockstore-{BLOCKSTORE_RS_NAME}-cert" + ) + return "blockstore" + + +@fixture(scope="module") +def ops_manager( + namespace, + ops_manager_issuer_ca_configmap: str, + app_db_issuer_ca_configmap: str, + appdb_certs_secret: str, + custom_version: Optional[str], + custom_appdb_version: str, +) -> MongoDBOpsManager: + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_backup_tls.yaml"), namespace=namespace + ) + resource.set_version(custom_version) + resource.set_appdb_version(custom_appdb_version) + resource.allow_mdb_rc_versions() + resource["spec"]["security"]["tls"]["ca"] = ops_manager_issuer_ca_configmap + resource["spec"]["applicationDatabase"]["security"]["tls"][ + "ca" + ] = app_db_issuer_ca_configmap + + create_or_update(resource) + return resource + + +@fixture(scope="module") +def oplog_replica_set( + ops_manager, app_db_issuer_ca_configmap: str, oplog_certs_secret: str +) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=ops_manager.namespace, + name=OPLOG_RS_NAME, + ).configure(ops_manager, "development") + resource.configure_custom_tls(app_db_issuer_ca_configmap, oplog_certs_secret) + + create_or_update(resource) + return resource + + +@fixture(scope="module") +def blockstore_replica_set( + ops_manager, app_db_issuer_ca_configmap: str, blockstore_certs_secret: str +) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=ops_manager.namespace, + name=BLOCKSTORE_RS_NAME, + ).configure(ops_manager, "blockstore") + resource.configure_custom_tls(app_db_issuer_ca_configmap, blockstore_certs_secret) + + create_or_update(resource) + return resource + + +@mark.e2e_om_ops_manager_backup_tls +class TestOpsManagerCreation: + def test_create_om(self, ops_manager: MongoDBOpsManager): + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=600) + + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=600) + + # appdb rolling restart for configuring monitoring + ops_manager.appdb_status().assert_abandons_phase(Phase.Running, timeout=200) + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=600) + + ops_manager.backup_status().assert_reaches_phase( + Phase.Pending, + msg_regexp="The MongoDB object .+ doesn't exist", + timeout=900, + ) + + def test_backing_dbs_created( + self, oplog_replica_set: MongoDB, blockstore_replica_set: MongoDB + ): + oplog_replica_set.assert_reaches_phase(Phase.Running) + blockstore_replica_set.assert_reaches_phase(Phase.Running) + + def test_oplog_running(self, oplog_replica_set: MongoDB, ca_path: str): + oplog_replica_set.assert_connectivity(ca_path=ca_path) + + def test_blockstore_running(self, blockstore_replica_set: MongoDB, ca_path: str): + blockstore_replica_set.assert_connectivity(ca_path=ca_path) + + def test_om_is_running( + self, + ops_manager: MongoDBOpsManager, + oplog_replica_set: MongoDB, + blockstore_replica_set: MongoDB, + ): + ops_manager.backup_status().assert_reaches_phase(Phase.Running, timeout=200) + om_tester = ops_manager.get_om_tester() + om_tester.assert_healthiness() + om_tester.assert_oplog_stores([new_om_data_store(oplog_replica_set, "oplog1")]) + om_tester.assert_block_stores( + [new_om_data_store(blockstore_replica_set, "blockStore1")] + ) + + +@mark.e2e_om_ops_manager_backup_tls +class TestBackupForMongodb: + """This part ensures that backup for the client works correctly and the snapshot is created.""" + + @fixture(scope="class") + def mdb_latest( + self, ops_manager: MongoDBOpsManager, namespace, custom_mdb_version: str + ): + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name="mdb-four-two", + ).configure(ops_manager, "firstProject") + # MongoD versions greater than 4.2.0 must be enterprise build to enable backup + resource["spec"]["version"] = ensure_ent_version(custom_mdb_version) + resource.configure_backup(mode="enabled") + create_or_update(resource) + return resource + + @fixture(scope="class") + def mdb_prev( + self, ops_manager: MongoDBOpsManager, namespace, custom_mdb_prev_version: str + ): + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name="mdb-four-zero", + ).configure(ops_manager, "secondProject") + resource["spec"]["version"] = ensure_ent_version(custom_mdb_prev_version) + resource.configure_backup(mode="enabled") + create_or_update(resource) + return resource + + def test_mdbs_created(self, mdb_latest: MongoDB, mdb_prev: MongoDB): + mdb_latest.assert_reaches_phase(Phase.Running) + mdb_prev.assert_reaches_phase(Phase.Running) + + def test_mdbs_backed_up(self, ops_manager: MongoDBOpsManager): + om_tester_first = ops_manager.get_om_tester(project_name="firstProject") + om_tester_second = ops_manager.get_om_tester(project_name="secondProject") + + # wait until a first snapshot is ready for both + om_tester_first.wait_until_backup_snapshots_are_ready(expected_count=1) + om_tester_second.wait_until_backup_snapshots_are_ready(expected_count=1) diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_tls_custom_ca.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_tls_custom_ca.py new file mode 100644 index 000000000..61c6e7fe6 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_backup_tls_custom_ca.py @@ -0,0 +1,305 @@ +import os +import time +from typing import Optional + +from kubetester import MongoDB, create_or_update +from kubetester.certs import create_ops_manager_tls_certs +from kubetester.kubetester import fixture as yaml_fixture, KubernetesTester +from kubetester.mongodb import Phase +from kubetester.opsmanager import MongoDBOpsManager +from pytest import mark, fixture +from tests.conftest import ( + default_external_domain, + external_domain_fqdns, + update_coredns_hosts, +) +from tests.opsmanager.conftest import ensure_ent_version +from tests.opsmanager.om_ops_manager_backup import ( + OPLOG_RS_NAME, + S3_SECRET_NAME, + BLOCKSTORE_RS_NAME, +) +from tests.opsmanager.om_ops_manager_https import create_mongodb_tls_certs + +OPLOG_SECRET_NAME = S3_SECRET_NAME + "-oplog" +FIRST_PROJECT_RS_NAME = "mdb-four-two" +SECOND_PROJECT_RS_NAME = "mdb-four-zero" +EXTERNAL_DOMAIN_RS_NAME = "my-replica-set" + +""" +This test checks the work with TLS-enabled backing databases (oplog & blockstore) + +Tests checking externalDomain (consider updating all of them when changing test logic): + tls/tls_replica_set_process_hostnames.py + replicaset/replica_set_process_hostnames.py + opsmanager/om_ops_manager_backup_tls_custom_ca.py +""" + + +@fixture(scope="module") +def ops_manager_certs(namespace: str, issuer: str): + prefix = "prefix" + create_ops_manager_tls_certs(issuer, namespace, "om-backup-tls", secret_name=f"{prefix}-om-backup-tls-cert") + return prefix + + +@fixture(scope="module") +def appdb_certs_secret(namespace: str, issuer: str): + prefix = "appdb" + create_mongodb_tls_certs(issuer, namespace, "om-backup-tls-db", f"{prefix}-om-backup-tls-db-cert") + return prefix + + +@fixture(scope="module") +def oplog_certs_secret(namespace: str, issuer: str): + create_mongodb_tls_certs(issuer, namespace, OPLOG_RS_NAME, f"oplog-{OPLOG_RS_NAME}-cert") + return "oplog" + + +@fixture(scope="module") +def blockstore_certs_secret(namespace: str, issuer: str): + create_mongodb_tls_certs(issuer, namespace, BLOCKSTORE_RS_NAME, f"blockstore-{BLOCKSTORE_RS_NAME}-cert") + return "blockstore" + + +@fixture(scope="module") +def first_project_certs(namespace: str, issuer: str): + create_mongodb_tls_certs( + issuer, + namespace, + FIRST_PROJECT_RS_NAME, + f"first-project-{FIRST_PROJECT_RS_NAME}-cert", + ) + return "first-project" + + +@fixture(scope="module") +def second_project_certs(namespace: str, issuer: str): + create_mongodb_tls_certs( + issuer, + namespace, + SECOND_PROJECT_RS_NAME, + f"second-project-{SECOND_PROJECT_RS_NAME}-cert", + ) + return "second-project" + + +@fixture(scope="module") +def mdb_external_domain_certs(namespace: str, issuer: str): + create_mongodb_tls_certs( + issuer, + namespace, + EXTERNAL_DOMAIN_RS_NAME, + f"external-domain-{EXTERNAL_DOMAIN_RS_NAME}-cert", + process_hostnames=external_domain_fqdns(EXTERNAL_DOMAIN_RS_NAME, 3), + ) + # prefix for certificates + return "external-domain" + + +@fixture(scope="module") +def ops_manager( + namespace, + issuer_ca_configmap: str, + appdb_certs_secret: str, + custom_version: Optional[str], + custom_appdb_version: str, + ops_manager_certs: str, + issuer_ca_filepath: str, +) -> MongoDBOpsManager: + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_backup_tls.yaml"), namespace=namespace + ) + + resource.set_version(custom_version) + resource.set_appdb_version(custom_appdb_version) + resource.allow_mdb_rc_versions() + resource["spec"]["security"]["certsSecretPrefix"] = ops_manager_certs + + # ensure the requests library will use this CA when communicating with Ops Manager + os.environ["REQUESTS_CA_BUNDLE"] = issuer_ca_filepath + + # configure OM to be using hybrid mode + resource["spec"]["configuration"]["automation.versions.source"] = "hybrid" + + create_or_update(resource) + return resource + + +@fixture(scope="module") +def oplog_replica_set(ops_manager, issuer_ca_configmap: str, oplog_certs_secret: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=ops_manager.namespace, + name=OPLOG_RS_NAME, + ).configure(ops_manager, "oplog") + resource.configure_custom_tls(issuer_ca_configmap, oplog_certs_secret) + + create_or_update(resource) + return resource + + +@fixture(scope="module") +def blockstore_replica_set(ops_manager, issuer_ca_configmap: str, blockstore_certs_secret: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=ops_manager.namespace, + name=BLOCKSTORE_RS_NAME, + ).configure(ops_manager, "blockstore") + resource.configure_custom_tls(issuer_ca_configmap, blockstore_certs_secret) + + create_or_update(resource) + return resource + + +@mark.e2e_om_ops_manager_backup_tls_custom_ca +def test_update_coredns(): + hosts = [ + ("172.18.255.200", "my-replica-set-0.mongodb.interconnected"), + ("172.18.255.201", "my-replica-set-1.mongodb.interconnected"), + ("172.18.255.202", "my-replica-set-2.mongodb.interconnected"), + ("172.18.255.203", "my-replica-set-3.mongodb.interconnected"), + ] + + update_coredns_hosts(hosts) + + +@mark.e2e_om_ops_manager_backup_tls_custom_ca +class TestOpsManagerCreation: + def test_create_om(self, ops_manager: MongoDBOpsManager): + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=600) + + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=900) + + # appdb rolling restart for configuring monitoring + ops_manager.appdb_status().assert_abandons_phase(Phase.Running, timeout=200) + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=600) + + ops_manager.backup_status().assert_reaches_phase( + Phase.Pending, + msg_regexp="The MongoDB object .+ doesn't exist", + timeout=900, + ) + + def test_add_custom_ca_to_project_configmap( + self, ops_manager: MongoDBOpsManager, issuer_ca_plus: str, namespace: str + ): + projects = [ + ops_manager.get_or_create_mongodb_connection_config_map(OPLOG_RS_NAME, "oplog"), + ops_manager.get_or_create_mongodb_connection_config_map(BLOCKSTORE_RS_NAME, "blockstore"), + ops_manager.get_or_create_mongodb_connection_config_map(FIRST_PROJECT_RS_NAME, "firstProject"), + ops_manager.get_or_create_mongodb_connection_config_map(SECOND_PROJECT_RS_NAME, "secondProject"), + ops_manager.get_or_create_mongodb_connection_config_map(EXTERNAL_DOMAIN_RS_NAME, "externalDomain"), + ] + + data = { + "sslMMSCAConfigMap": issuer_ca_plus, + } + + for project in projects: + KubernetesTester.update_configmap(namespace, project, data) + + # Give a few seconds for the operator to catch the changes on + # the project ConfigMaps + time.sleep(10) + + def test_backing_dbs_created(self, blockstore_replica_set: MongoDB, oplog_replica_set: MongoDB): + oplog_replica_set.assert_reaches_phase(Phase.Running) + blockstore_replica_set.assert_reaches_phase(Phase.Running) + + def test_oplog_running(self, oplog_replica_set: MongoDB, ca_path: str): + oplog_replica_set.assert_connectivity(ca_path=ca_path) + + def test_blockstore_running(self, blockstore_replica_set: MongoDB, ca_path: str): + blockstore_replica_set.assert_connectivity(ca_path=ca_path) + + def test_om_is_running( + self, + ops_manager: MongoDBOpsManager, + ): + ops_manager.backup_status().assert_reaches_phase(Phase.Running, timeout=200) + + +@mark.e2e_om_ops_manager_backup_tls_custom_ca +class TestBackupForMongodb: + """This part ensures that backup for the client works correctly and the snapshot is created.""" + + @fixture(scope="class") + def mdb_latest( + self, + ops_manager: MongoDBOpsManager, + namespace, + custom_mdb_version: str, + issuer_ca_configmap: str, + first_project_certs: str, + ): + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=FIRST_PROJECT_RS_NAME, + ).configure(ops_manager, "firstProject") + # MongoD versions greater than 4.2.0 must be enterprise build to enable backup + resource["spec"]["version"] = ensure_ent_version(custom_mdb_version) + resource.configure_backup(mode="enabled") + resource.configure_custom_tls(issuer_ca_configmap, first_project_certs) + create_or_update(resource) + return resource + + @fixture(scope="class") + def mdb_prev( + self, + ops_manager: MongoDBOpsManager, + namespace, + custom_mdb_prev_version: str, + issuer_ca_configmap: str, + second_project_certs: str, + ): + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=SECOND_PROJECT_RS_NAME, + ).configure(ops_manager, "secondProject") + resource["spec"]["version"] = ensure_ent_version(custom_mdb_prev_version) + resource.configure_backup(mode="enabled") + resource.configure_custom_tls(issuer_ca_configmap, second_project_certs) + create_or_update(resource) + return resource + + @fixture(scope="class") + def mdb_external_domain( + self, + ops_manager: MongoDBOpsManager, + namespace, + custom_mdb_prev_version: str, + issuer_ca_configmap: str, + mdb_external_domain_certs: str, + ): + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=EXTERNAL_DOMAIN_RS_NAME, + ).configure(ops_manager, "externalDomain") + + resource["spec"]["version"] = ensure_ent_version(custom_mdb_prev_version) + resource.configure_backup(mode="enabled") + resource.configure_custom_tls(issuer_ca_configmap, mdb_external_domain_certs) + resource["spec"]["members"] = 3 + resource["spec"]["externalAccess"] = {} + resource["spec"]["externalAccess"]["externalDomain"] = default_external_domain() + create_or_update(resource) + return resource + + def test_mdbs_created(self, mdb_latest: MongoDB, mdb_prev: MongoDB, mdb_external_domain: MongoDB): + mdb_latest.assert_reaches_phase(Phase.Running) + mdb_prev.assert_reaches_phase(Phase.Running) + mdb_external_domain.assert_reaches_phase(Phase.Running) + + def test_mdbs_backed_up(self, ops_manager: MongoDBOpsManager): + om_tester_first = ops_manager.get_om_tester(project_name="firstProject") + om_tester_second = ops_manager.get_om_tester(project_name="secondProject") + om_tester_external_domain = ops_manager.get_om_tester(project_name="externalDomain") + + # wait until a first snapshot is ready for both + om_tester_first.wait_until_backup_snapshots_are_ready(expected_count=1) + om_tester_second.wait_until_backup_snapshots_are_ready(expected_count=1) + om_tester_external_domain.wait_until_backup_snapshots_are_ready(expected_count=1) diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_feature_controls.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_feature_controls.py new file mode 100644 index 000000000..611764611 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_feature_controls.py @@ -0,0 +1,144 @@ +from typing import Optional +from pytest import fixture, mark + +from kubetester.mongodb import Phase, MongoDB +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.opsmanager import MongoDBOpsManager + + +@fixture(scope="module") +def ops_manager( + namespace: str, custom_version: Optional[str], custom_appdb_version: str +) -> MongoDBOpsManager: + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_basic.yaml"), namespace=namespace + ) + resource.set_version(custom_version) + resource.set_appdb_version(custom_appdb_version) + + return resource.create() + + +@fixture(scope="module") +def replica_set( + ops_manager: MongoDBOpsManager, namespace: str, custom_mdb_version: str +) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name="mdb", + ).configure(ops_manager, "mdb") + resource["spec"]["version"] = custom_mdb_version + return resource.create() + + +@mark.e2e_om_feature_controls +def test_create_om(ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=900) + + +@mark.e2e_om_feature_controls +def test_replica_set_reaches_running_phase(replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running, timeout=600, ignore_errors=True) + + +@mark.e2e_om_feature_controls +def test_authentication_is_owned_by_opsmanager(replica_set: MongoDB): + """ + There is no authentication, so feature controls API should allow + authentication changes from Ops Manager UI. + """ + fc = replica_set.get_om_tester().get_feature_controls() + assert fc["externalManagementSystem"]["name"] == "mongodb-enterprise-operator" + + assert len(fc["policies"]) == 2 + policies = [p["policy"] for p in fc["policies"]] + assert "EXTERNALLY_MANAGED_LOCK" in policies + assert "DISABLE_SET_MONGOD_VERSION" in policies + + for p in fc["policies"]: + if p["policy"] == "EXTERNALLY_MANAGED_LOCK": + assert p["disabledParams"] == [] + + +@mark.e2e_om_feature_controls +def test_authentication_disabled_is_owned_by_operator(replica_set: MongoDB): + """ + Authentication has been added to the Spec, on "disabled" mode, + this makes the Operator to *own* authentication and thus + making Feature controls API to restrict any + """ + replica_set["spec"]["security"] = {"authentication": {"enabled": False}} + replica_set.update() + + replica_set.assert_abandons_phase(Phase.Running) + replica_set.assert_reaches_phase(Phase.Running, timeout=600) + + fc = replica_set.get_om_tester().get_feature_controls() + assert fc["externalManagementSystem"]["name"] == "mongodb-enterprise-operator" + + policies = sorted(fc["policies"], key=lambda policy: policy["policy"]) + assert len(fc["policies"]) == 3 + + assert policies[0]["disabledParams"] == [] + assert policies[2]["disabledParams"] == [] + + policies = [p["policy"] for p in fc["policies"]] + assert "EXTERNALLY_MANAGED_LOCK" in policies + assert "DISABLE_AUTHENTICATION_MECHANISMS" in policies + assert "DISABLE_SET_MONGOD_VERSION" in policies + + +@mark.e2e_om_feature_controls +def test_authentication_enabled_is_owned_by_operator(replica_set: MongoDB): + """ + Authentication has been enabled on the Operator. Authentication is still + owned by the operator so feature controls should be kept the same. + """ + replica_set["spec"]["security"] = { + "authentication": {"enabled": True, "modes": ["SCRAM"]} + } + replica_set.update() + + replica_set.assert_abandons_phase(Phase.Running) + replica_set.assert_reaches_phase(Phase.Running, timeout=600) + + fc = replica_set.get_om_tester().get_feature_controls() + + assert fc["externalManagementSystem"]["name"] == "mongodb-enterprise-operator" + + assert len(fc["policies"]) == 3 + # sort the policies to have pre-determined order + policies = sorted(fc["policies"], key=lambda policy: policy["policy"]) + assert policies[0]["disabledParams"] == [] + assert policies[2]["disabledParams"] == [] + + policies = [p["policy"] for p in fc["policies"]] + assert "EXTERNALLY_MANAGED_LOCK" in policies + assert "DISABLE_AUTHENTICATION_MECHANISMS" in policies + assert "DISABLE_SET_MONGOD_VERSION" in policies + + +@mark.e2e_om_feature_controls +def test_authentication_disabled_owned_by_opsmanager(replica_set: MongoDB): + """ + Authentication has been disabled (removed) on the Operator. Authentication + is now "owned" by Ops Manager. + """ + last_transition = replica_set.get_status_last_transition_time() + replica_set["spec"]["security"] = None + replica_set.update() + + replica_set.assert_state_transition_happens(last_transition) + replica_set.assert_reaches_phase(Phase.Running, timeout=600) + + fc = replica_set.get_om_tester().get_feature_controls() + + assert fc["externalManagementSystem"]["name"] == "mongodb-enterprise-operator" + + # sort the policies to have pre-determined order + policies = sorted(fc["policies"], key=lambda policy: policy["policy"]) + assert len(fc["policies"]) == 2 + assert policies[0]["policy"] == "DISABLE_SET_MONGOD_VERSION" + assert policies[1]["policy"] == "EXTERNALLY_MANAGED_LOCK" + assert policies[1]["disabledParams"] == [] diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_https.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_https.py new file mode 100644 index 000000000..346d1e8a0 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_https.py @@ -0,0 +1,181 @@ +import time +from typing import Optional + +from kubetester import create_or_update +from kubetester.certs import ( + create_mongodb_tls_certs, + create_ops_manager_tls_certs, + Certificate, +) +from kubetester.kubetester import KubernetesTester, fixture as _fixture, skip_if_local +from kubetester.mongodb import MongoDB, Phase +from kubetester.opsmanager import MongoDBOpsManager +from pytest import fixture, mark + + +@fixture(scope="module") +def appdb_certs(namespace: str, issuer: str) -> str: + create_mongodb_tls_certs(issuer, namespace, "om-with-https-db", "appdb-om-with-https-db-cert") + return "appdb" + + +@fixture(scope="module") +def ops_manager_certs(namespace: str, issuer: str): + return create_ops_manager_tls_certs(issuer, namespace, "om-with-https", "prefix-om-with-https-cert") + + +@fixture(scope="module") +def ops_manager( + namespace: str, + issuer_ca_configmap: str, + appdb_certs: str, + ops_manager_certs: str, + custom_version: Optional[str], + custom_appdb_version: str, +) -> MongoDBOpsManager: + om: MongoDBOpsManager = MongoDBOpsManager.from_yaml(_fixture("om_https_enabled.yaml"), namespace=namespace) + om.set_version(custom_version) + om.set_appdb_version(custom_appdb_version) + om.allow_mdb_rc_versions() + + return create_or_update(om) + + +@fixture(scope="module") +def replicaset0(ops_manager: MongoDBOpsManager, namespace: str, custom_mdb_version: str): + """First replicaset to be created before Ops Manager is configured with HTTPS.""" + resource = MongoDB.from_yaml(_fixture("replica-set.yaml"), name="replicaset0", namespace=namespace).configure( + ops_manager, "replicaset0" + ) + resource["spec"]["version"] = custom_mdb_version + + return create_or_update(resource) + + +@fixture(scope="module") +def replicaset1(ops_manager: MongoDBOpsManager, namespace: str, custom_mdb_version: str): + """Second replicaset to be created when Ops Manager was restarted with HTTPS.""" + resource = MongoDB.from_yaml(_fixture("replica-set.yaml"), name="replicaset1", namespace=namespace).configure( + ops_manager, "replicaset1" + ) + resource["spec"]["version"] = custom_mdb_version + + return create_or_update(resource) + + +@mark.e2e_om_ops_manager_https_enabled +def test_om_created_no_tls(ops_manager: MongoDBOpsManager): + """Ops Manager is started over plain HTTP. AppDB also doesn't have TLS enabled""" + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=900) + + assert ops_manager.om_status().get_url().startswith("http://") + assert ops_manager.om_status().get_url().endswith(":8080") + + ops_manager.appdb_status().assert_abandons_phase(Phase.Running, timeout=100) + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=400) + + +@mark.e2e_om_ops_manager_https_enabled +def test_appdb_running_no_tls(ops_manager: MongoDBOpsManager): + ops_manager.get_appdb_tester().assert_connectivity() + + +@mark.e2e_om_ops_manager_https_enabled +def test_appdb_enable_tls(ops_manager: MongoDBOpsManager, issuer_ca_configmap: str, appdb_certs: str): + """Enable TLS for the AppDB (not for OM though).""" + ops_manager.load() + ops_manager["spec"]["applicationDatabase"]["security"] = { + "certsSecretPrefix": appdb_certs, + "tls": {"ca": issuer_ca_configmap}, + } + ops_manager.update() + ops_manager.appdb_status().assert_abandons_phase(Phase.Running, timeout=60) + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=600) + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=900) + + +@mark.e2e_om_ops_manager_https_enabled +def test_appdb_running_over_tls(ops_manager: MongoDBOpsManager, ca_path: str): + ops_manager.get_appdb_tester(ssl=True, ca_path=ca_path).assert_connectivity() + + +@mark.e2e_om_ops_manager_https_enabled +def test_appdb_not_connectibel_without_tls(ops_manager: MongoDBOpsManager): + ops_manager.get_appdb_tester().assert_no_connection() + + +@mark.e2e_om_ops_manager_https_enabled +def test_replica_set_over_non_https_ops_manager(replicaset0: MongoDB): + """First replicaset is started over non-HTTPS Ops Manager.""" + replicaset0.assert_reaches_phase(Phase.Running) + replicaset0.assert_connectivity() + + +@mark.e2e_om_ops_manager_https_enabled +def test_enable_https_on_opsmanager(ops_manager: MongoDBOpsManager, issuer_ca_configmap: str, ops_manager_certs: str): + """Ops Manager is restarted with HTTPS enabled.""" + ops_manager.load() + ops_manager["spec"]["security"] = { + "certsSecretPrefix": "prefix", + "tls": {"ca": issuer_ca_configmap}, + } + ops_manager.update() + + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=900) + + assert ops_manager.om_status().get_url().startswith("https://") + assert ops_manager.om_status().get_url().endswith(":8443") + + +@mark.e2e_om_ops_manager_https_enabled +def test_project_is_configured_with_custom_ca( + ops_manager: MongoDBOpsManager, + namespace: str, + issuer_ca_configmap: str, +): + """Both projects are configured with the new HTTPS enabled Ops Manager.""" + project1 = ops_manager.get_or_create_mongodb_connection_config_map("replicaset0", "replicaset0") + project2 = ops_manager.get_or_create_mongodb_connection_config_map("replicaset1", "replicaset1") + + data = { + "sslMMSCAConfigMap": issuer_ca_configmap, + } + KubernetesTester.update_configmap(namespace, project1, data) + KubernetesTester.update_configmap(namespace, project2, data) + + # Give a few seconds for the operator to catch the changes on + # the project ConfigMaps + time.sleep(10) + + +@mark.e2e_om_ops_manager_https_enabled +def test_mongodb_replicaset_over_https_ops_manager(replicaset0: MongoDB, replicaset1: MongoDB): + """Both replicasets get to running state and are reachable. + Note that 'replicaset1' is created just now.""" + + # TODO: Find a way to fix this, after discussing CLOUDP-92131. + # replicaset0.assert_reaches_phase(Phase.Running, timeout=360) + # replicaset0.assert_connectivity() + + replicaset1.assert_reaches_phase(Phase.Running, timeout=360) + replicaset1.assert_connectivity() + + +@mark.e2e_om_ops_manager_https_enabled +def test_change_om_certificate_and_wait_for_running(ops_manager: MongoDBOpsManager, namespace: str): + cert = Certificate(name="prefix-om-with-https-cert", namespace=namespace).load() + cert["spec"]["dnsNames"].append("foo") + cert.update() + ops_manager.om_status().assert_abandons_phase(Phase.Running, timeout=60) + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=600) + assert ops_manager.om_status().get_url().startswith("https://") + assert ops_manager.om_status().get_url().endswith(":8443") + + +@mark.e2e_om_ops_manager_https_enabled +def test_change_appdb_certificate_and_wait_for_running(ops_manager: MongoDBOpsManager, namespace: str): + cert = Certificate(name="appdb-om-with-https-db-cert", namespace=namespace).load() + cert["spec"]["dnsNames"].append("foo") + cert.update() + ops_manager.appdb_status().assert_abandons_phase(Phase.Running, timeout=60) + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=600) diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_https_hybrid_mode.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_https_hybrid_mode.py new file mode 100644 index 000000000..b236dcde6 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_https_hybrid_mode.py @@ -0,0 +1,116 @@ +import time +from typing import Optional + +from kubetester.certs import ( + create_mongodb_tls_certs, + create_ops_manager_tls_certs, + Certificate, +) +from kubetester.kubetester import KubernetesTester, fixture as _fixture +from kubetester.mongodb import MongoDB, Phase +from kubetester.opsmanager import MongoDBOpsManager +from pytest import fixture, mark + + +@fixture(scope="module") +def certs_secret_prefix(namespace: str, issuer: str): + create_mongodb_tls_certs(issuer, namespace, "replicaset0", "certs-replicaset0-cert") + return "certs" + + +@fixture(scope="module") +def appdb_certs(namespace: str, issuer: str) -> str: + create_mongodb_tls_certs( + issuer, namespace, "om-with-https-db", "appdb-om-with-https-db-cert" + ) + return "appdb" + + +@fixture(scope="module") +def ops_manager_certs(namespace: str, issuer: str): + return create_ops_manager_tls_certs( + issuer, namespace, "om-with-https", secret_name="prefix-om-with-https-cert" + ) + + +@fixture(scope="module") +def ops_manager( + namespace: str, + issuer_ca_configmap: str, + appdb_certs: str, + ops_manager_certs: str, + custom_version: Optional[str], + custom_appdb_version: str, +) -> MongoDBOpsManager: + om: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + _fixture("om_https_enabled.yaml"), namespace=namespace + ) + om.set_version(custom_version) + om.set_appdb_version(custom_appdb_version) + om.allow_mdb_rc_versions() + del om["spec"]["statefulSet"] + om["spec"]["security"] = { + "certsSecretPrefix": "prefix", + "tls": { + "ca": issuer_ca_configmap, + }, + } + om["spec"]["configuration"]["automation.versions.source"] = "hybrid" + om["spec"]["applicationDatabase"]["security"] = { + "tls": { + "ca": issuer_ca_configmap, + }, + "certsSecretPrefix": appdb_certs, + } + return om.create() + + +@fixture(scope="module") +def replicaset0( + ops_manager: MongoDBOpsManager, + namespace: str, + custom_mdb_version: str, + issuer_ca_configmap: str, + certs_secret_prefix: str, +): + """First replicaset to be created before Ops Manager is configured with HTTPS.""" + resource = MongoDB.from_yaml( + _fixture("replica-set.yaml"), name="replicaset0", namespace=namespace + ).configure(ops_manager, "replicaset0") + resource["spec"]["version"] = custom_mdb_version + resource.configure_custom_tls(issuer_ca_configmap, certs_secret_prefix) + return resource.create() + + +@mark.e2e_om_ops_manager_https_enabled_hybrid +def test_appdb_running_over_tls(ops_manager: MongoDBOpsManager, ca_path: str): + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=600) + ops_manager.get_appdb_tester(ssl=True, ca_path=ca_path).assert_connectivity() + + +@mark.e2e_om_ops_manager_https_enabled_hybrid +def test_om_reaches_running_state(ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=1000) + + +@mark.e2e_om_ops_manager_https_enabled_hybrid +def test_config_map_has_ca_set_correctly( + ops_manager: MongoDBOpsManager, issuer_ca_plus: str, namespace: str +): + project1 = ops_manager.get_or_create_mongodb_connection_config_map( + "replicaset0", "replicaset0" + ) + data = { + "sslMMSCAConfigMap": issuer_ca_plus, + } + KubernetesTester.update_configmap(namespace, project1, data) + + # Give a few seconds for the operator to catch the changes on + # the project ConfigMaps + time.sleep(10) + + +@mark.e2e_om_ops_manager_https_enabled_hybrid +def test_mongodb_replicaset_over_https_ops_manager(replicaset0: MongoDB, ca_path: str): + replicaset0.assert_reaches_phase(Phase.Running, timeout=400, ignore_errors=True) + replicaset0.assert_connectivity(ca_path=ca_path) diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_https_internet_mode.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_https_internet_mode.py new file mode 100644 index 000000000..9d6b8c13f --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_https_internet_mode.py @@ -0,0 +1,127 @@ +import time +from typing import Optional + +from kubetester.certs import Certificate +from kubetester.certs import create_mongodb_tls_certs, create_ops_manager_tls_certs +from kubetester.kubetester import KubernetesTester, fixture as _fixture +from kubetester.mongodb import MongoDB, Phase +from kubetester.opsmanager import MongoDBOpsManager +from pytest import fixture, mark + + +@fixture(scope="module") +def appdb_certs(namespace: str, issuer: str): + create_mongodb_tls_certs( + issuer, namespace, "om-with-https-db", "appdb-om-with-https-db-cert" + ) + return "appdb" + + +@fixture(scope="module") +def ops_manager_certs(namespace: str, issuer: str): + return create_ops_manager_tls_certs( + issuer, namespace, "om-with-https", secret_name="prefix-om-with-https-cert" + ) + + +@fixture(scope="module") +def ops_manager( + namespace: str, + issuer_ca_plus: str, + appdb_certs: str, + ops_manager_certs: str, + custom_version: Optional[str], + custom_appdb_version: str, +) -> MongoDBOpsManager: + om: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + _fixture("om_https_enabled.yaml"), namespace=namespace + ) + om.set_version(custom_version) + + # do not use local mode + del om["spec"]["configuration"]["automation.versions.source"] + del om["spec"]["statefulSet"] + + om.set_appdb_version(custom_appdb_version) + # configure CA + tls secrets for AppDB members to community with each other + om["spec"]["applicationDatabase"]["security"] = { + "tls": {"ca": issuer_ca_plus, "secretRef": {"prefix": appdb_certs}} + } + + # configure the CA that will be used to communicate with Ops Manager + om["spec"]["security"] = { + "certsSecretPrefix": "prefix", + "tls": { + "ca": issuer_ca_plus, + }, + } + return om.create() + + +@fixture(scope="module") +def replicaset0(ops_manager: MongoDBOpsManager, namespace: str): + resource = MongoDB.from_yaml( + _fixture("replica-set.yaml"), name="replicaset0", namespace=namespace + ).configure(ops_manager, "replicaset0") + resource["spec"]["version"] = "4.0.20" + + return resource.create() + + +@fixture(scope="module") +def replicaset1(ops_manager: MongoDBOpsManager, namespace: str): + resource = MongoDB.from_yaml( + _fixture("replica-set.yaml"), name="replicaset1", namespace=namespace + ).configure(ops_manager, "replicaset1") + resource["spec"]["version"] = "4.2.8" + + return resource.create() + + +@mark.e2e_om_ops_manager_https_enabled_internet_mode +def test_enable_https_on_opsmanager(ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=900) + # some more time for monitoring rolling upgrade + ops_manager.appdb_status().assert_abandons_phase(Phase.Running, timeout=200) + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=600) + + assert ops_manager.om_status().get_url().startswith("https://") + assert ops_manager.om_status().get_url().endswith(":8443") + + +@mark.e2e_om_ops_manager_https_enabled_internet_mode +def test_project_is_configured_with_custom_ca( + ops_manager: MongoDBOpsManager, + namespace: str, + issuer_ca_plus: str, +): + """Both projects are configured with the new HTTPS enabled Ops Manager.""" + project1 = ops_manager.get_or_create_mongodb_connection_config_map( + "replicaset0", "replicaset0" + ) + project2 = ops_manager.get_or_create_mongodb_connection_config_map( + "replicaset1", "replicaset1" + ) + + data = { + "sslMMSCAConfigMap": issuer_ca_plus, + } + KubernetesTester.update_configmap(namespace, project1, data) + KubernetesTester.update_configmap(namespace, project2, data) + + # Give a few seconds for the operator to catch the changes on + # the project ConfigMaps + time.sleep(10) + + +@mark.e2e_om_ops_manager_https_enabled_internet_mode +def test_mongodb_replicaset_over_https_ops_manager( + replicaset0: MongoDB, replicaset1: MongoDB +): + """Both replicasets get to running state and are reachable.""" + + replicaset0.assert_reaches_phase(Phase.Running, timeout=360) + replicaset0.assert_connectivity() + + replicaset1.assert_reaches_phase(Phase.Running, timeout=360) + replicaset1.assert_connectivity() diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_https_prefix.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_https_prefix.py new file mode 100644 index 000000000..12e0df087 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_https_prefix.py @@ -0,0 +1,50 @@ +import time +from typing import Optional + +from kubetester.certs import create_ops_manager_tls_certs +from kubetester.kubetester import KubernetesTester, fixture as _fixture +from kubetester.mongodb import MongoDB, Phase +from kubetester.opsmanager import MongoDBOpsManager +from pytest import fixture, mark + + +@fixture(scope="module") +def ops_manager_certs(namespace: str, issuer: str): + return create_ops_manager_tls_certs( + issuer, namespace, "om-with-https", secret_name="prefix-om-with-https-cert" + ) + + +@fixture(scope="module") +def ops_manager( + namespace: str, + issuer_ca_configmap: str, + ops_manager_certs: str, + custom_version: Optional[str], + custom_appdb_version: str, +) -> MongoDBOpsManager: + om: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + _fixture("om_https_enabled.yaml"), namespace=namespace + ) + om.set_version(custom_version) + om.set_appdb_version(custom_appdb_version) + om["spec"]["security"] = { + "certsSecretPrefix": "prefix", + "tls": { + "ca": issuer_ca_configmap, + }, + } + + # No need to mount the additional mongods because they are not used + # in this test, and make the test slower. + om["spec"]["statefulSet"]["spec"]["template"]["spec"] = {} + + return om.create() + + +@mark.e2e_om_ops_manager_https_enabled_prefix +def test_om_created(ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=900) + + assert ops_manager.om_status().get_url().startswith("https://") + assert ops_manager.om_status().get_url().endswith(":8443") diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_local_mode_enable_and_disable_manually_deleting_sts.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_local_mode_enable_and_disable_manually_deleting_sts.py new file mode 100644 index 000000000..40c4ae70a --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_local_mode_enable_and_disable_manually_deleting_sts.py @@ -0,0 +1,105 @@ +from typing import Optional + +from kubetester import MongoDB +from kubetester.opsmanager import MongoDBOpsManager +from kubetester.kubetester import fixture as yaml_fixture, KubernetesTester + +from kubetester import ( + delete_statefulset, + delete_pod, + get_pod_when_ready, +) +from kubetester.mongodb import Phase +from pytest import mark, fixture + + +@fixture(scope="module") +def ops_manager( + namespace: str, + custom_version: Optional[str], + custom_appdb_version: str, +) -> MongoDBOpsManager: + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_basic.yaml"), namespace=namespace + ) + + resource["spec"]["replicas"] = 2 + resource.set_version(custom_version) + resource.set_appdb_version(custom_appdb_version) + resource.allow_mdb_rc_versions() + + return resource.create() + + +@fixture(scope="module") +def replica_set( + ops_manager: MongoDBOpsManager, namespace: str, custom_mdb_version: str +) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + ).configure(ops_manager, "my-replica-set") + resource["spec"]["version"] = custom_mdb_version + yield resource.create() + + +@mark.e2e_om_ops_manager_enable_local_mode_running_om +def test_create_om(ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=900) + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=900) + + +@mark.e2e_om_ops_manager_enable_local_mode_running_om +def test_enable_local_mode(ops_manager: MongoDBOpsManager, namespace: str): + + om = MongoDBOpsManager.from_yaml( + yaml_fixture("om_localmode-multiple-pv.yaml"), namespace=namespace + ) + + # We manually delete the ops manager sts, it won't delete the pods as + # the function by default does cascade=false + delete_statefulset(namespace, ops_manager.name) + ops_manager.load() + ops_manager["spec"]["configuration"] = {"automation.versions.source": "local"} + ops_manager["spec"]["statefulSet"] = om["spec"]["statefulSet"] + ops_manager.update() + + # At this point the operator has created a new sts but the existing pods can't be bound to + # it because podspecs are immutable so the volumes field can't be changed + # and thus we can't rollout + + for i in range(2): + # So we manually delete one, wait for it to be ready + # and do the same for the second one + delete_pod(namespace, f"om-basic-{i}") + get_pod_when_ready( + namespace, f"statefulset.kubernetes.io/pod-name=om-basic-{i}" + ) + + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=900) + + +@mark.e2e_om_ops_manager_enable_local_mode_running_om +def test_add_mongodb_distros(ops_manager: MongoDBOpsManager, custom_mdb_version: str): + ops_manager.download_mongodb_binaries(custom_mdb_version) + + +@mark.e2e_om_ops_manager_enable_local_mode_running_om +def test_new_binaries_are_present(ops_manager: MongoDBOpsManager, namespace: str): + cmd = [ + "/bin/sh", + "-c", + "ls /mongodb-ops-manager/mongodb-releases/*.tgz", + ] + for i in range(2): + result = KubernetesTester.run_command_in_pod_container( + f"om-basic-{i}", + namespace, + cmd, + ) + assert result != "0" + + +@mark.e2e_om_ops_manager_enable_local_mode_running_om +def test_replica_set_reaches_running_phase(replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running, timeout=600) diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_prometheus.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_prometheus.py new file mode 100644 index 000000000..ebcea4501 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_prometheus.py @@ -0,0 +1,245 @@ +import time + +from kubernetes import client +from kubetester import MongoDB, create_secret, random_k8s_name +from kubetester.certs import create_mongodb_tls_certs +from kubetester.http import https_endpoint_is_reachable +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.mongodb import Phase, generic_replicaset +from kubetester.opsmanager import MongoDBOpsManager +from pytest import fixture, mark + + +def certs_for_prometheus(issuer: str, namespace: str, resource_name: str) -> str: + secret_name = random_k8s_name(resource_name + "-") + "-cert" + + return create_mongodb_tls_certs( + issuer, + namespace, + resource_name, + secret_name, + ) + + +CONFIGURED_PROMETHEUS_PORT = 9999 + + +@fixture(scope="module") +def ops_manager( + namespace: str, + custom_appdb_version: str, + custom_version: str, + issuer: str, +) -> MongoDBOpsManager: + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_basic.yaml"), namespace=namespace + ) + + resource.set_version(custom_version) + resource.set_appdb_version(custom_appdb_version) + resource.allow_mdb_rc_versions() + resource["spec"]["replicas"] = 1 + + create_secret(namespace, "appdb-prom-secret", {"password": "prom-password"}) + + prom_cert_secret = certs_for_prometheus(issuer, namespace, resource.name + "-db") + resource["spec"]["applicationDatabase"]["prometheus"] = { + "username": "prom-user", + "passwordSecretRef": {"name": "appdb-prom-secret"}, + "tlsSecretKeyRef": { + "name": prom_cert_secret, + }, + } + + return resource.create() + + +@fixture(scope="module") +def sharded_cluster( + ops_manager: MongoDBOpsManager, namespace: str, issuer: str +) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("sharded-cluster.yaml"), + namespace=namespace, + ) + prom_cert_secret = certs_for_prometheus(issuer, namespace, resource.name) + + create_secret(namespace, "cluster-secret", {"password": "cluster-prom-password"}) + + resource["spec"]["prometheus"] = { + "username": "prom-user", + "passwordSecretRef": { + "name": "cluster-secret", + }, + "tlsSecretKeyRef": { + "name": prom_cert_secret, + }, + "port": CONFIGURED_PROMETHEUS_PORT, + } + del resource["spec"]["cloudManager"] + resource.configure(ops_manager, namespace) + + yield resource.create() + + +@fixture(scope="module") +def replica_set( + ops_manager: MongoDBOpsManager, + namespace: str, + custom_mdb_version: str, + issuer: str, +) -> MongoDB: + + create_secret(namespace, "rs-secret", {"password": "prom-password"}) + + resource = generic_replicaset( + namespace, "5.0.6", "replica-set-with-prom", ops_manager + ) + + prom_cert_secret = certs_for_prometheus(issuer, namespace, resource.name) + resource["spec"]["prometheus"] = { + "username": "prom-user", + "passwordSecretRef": { + "name": "rs-secret", + }, + "tlsSecretKeyRef": { + "name": prom_cert_secret, + }, + } + yield resource.create() + + +@mark.e2e_om_ops_manager_prometheus +def test_create_om(ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=900) + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=900) + + +@mark.e2e_om_ops_manager_prometheus +def test_create_replica_set(replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running, timeout=600) + + +@mark.e2e_om_ops_manager_prometheus +def test_prometheus_endpoint_works_on_every_pod(replica_set: MongoDB, namespace: str): + members = replica_set["spec"]["members"] + name = replica_set.name + + auth = ("prom-user", "prom-password") + + for idx in range(members): + member_url = f"https://{name}-{idx}.{name}-svc.{namespace}.svc.cluster.local:9216/metrics" + assert https_endpoint_is_reachable(member_url, auth, tls_verify=False) + + +@mark.e2e_om_ops_manager_prometheus +def test_prometheus_can_change_credentials(replica_set: MongoDB): + replica_set["spec"]["prometheus"] = {"username": "prom-user-but-changed-this-time"} + replica_set.update() + + # TODO: is the resource even being moved away from Running phase? + time.sleep(20) + replica_set.assert_reaches_phase(Phase.Running, timeout=600) + + +@mark.e2e_om_ops_manager_prometheus +def test_prometheus_endpoint_works_on_every_pod_with_changed_username( + replica_set: MongoDB, namespace: str +): + members = replica_set["spec"]["members"] + name = replica_set.name + + auth = ("prom-user-but-changed-this-time", "prom-password") + + for idx in range(members): + member_url = f"https://{name}-{idx}.{name}-svc.{namespace}.svc.cluster.local:9216/metrics" + assert https_endpoint_is_reachable(member_url, auth, tls_verify=False) + + +@mark.e2e_om_ops_manager_prometheus +def test_create_sharded_cluster(sharded_cluster: MongoDB): + sharded_cluster.assert_reaches_phase(Phase.Running, timeout=600) + + +@mark.e2e_om_ops_manager_prometheus +def test_prometheus_endpoint_works_on_every_pod_on_the_cluster( + sharded_cluster: MongoDB, namespace: str +): + """ + Checks that all of the Prometheus endpoints that we expect are up and listening. + """ + + auth = ("prom-user", "cluster-prom-password") + name = sharded_cluster.name + + port = sharded_cluster["spec"]["prometheus"]["port"] + mongos_count = sharded_cluster["spec"]["mongosCount"] + for idx in range(mongos_count): + url = f"https://{name}-mongos-{idx}.{name}-svc.{namespace}.svc.cluster.local:{port}/metrics" + assert https_endpoint_is_reachable(url, auth, tls_verify=False) + + shard_count = sharded_cluster["spec"]["shardCount"] + mongodbs_per_shard_count = sharded_cluster["spec"]["mongodsPerShardCount"] + for shard in range(shard_count): + for mongodb in range(mongodbs_per_shard_count): + url = f"https://{name}-{shard}-{mongodb}.{name}-sh.{namespace}.svc.cluster.local:{port}/metrics" + assert https_endpoint_is_reachable(url, auth, tls_verify=False) + + config_server_count = sharded_cluster["spec"]["configServerCount"] + for idx in range(config_server_count): + url = f"https://{name}-config-{idx}.{name}-cs.{namespace}.svc.cluster.local:{port}/metrics" + assert https_endpoint_is_reachable(url, auth, tls_verify=False) + + +@mark.e2e_om_ops_manager_prometheus +def test_sharded_cluster_service_has_been_updated_with_prometheus_port( + replica_set: MongoDB, sharded_cluster: MongoDB +): + # Check that the service that belong to the Replica Set has the + # the default Prometheus port. + assert_mongodb_prometheus_port_exist( + replica_set.name + "-svc", + replica_set.namespace, + port=9216, + ) + + # Checks that the Services that belong to the Sharded cluster have + # the configured Prometheus port. + assert_mongodb_prometheus_port_exist( + sharded_cluster.name + "-svc", + sharded_cluster.namespace, + port=CONFIGURED_PROMETHEUS_PORT, + ) + assert_mongodb_prometheus_port_exist( + sharded_cluster.name + "-cs", + sharded_cluster.namespace, + port=CONFIGURED_PROMETHEUS_PORT, + ) + assert_mongodb_prometheus_port_exist( + sharded_cluster.name + "-sh", + sharded_cluster.namespace, + port=CONFIGURED_PROMETHEUS_PORT, + ) + + +@mark.e2e_om_ops_manager_prometheus +def test_prometheus_endpoint_works_on_every_pod_on_appdb(ops_manager: MongoDB): + auth = ("prom-user", "prom-password") + name = ops_manager.name + "-db" + + for idx in range(ops_manager["spec"]["applicationDatabase"]["members"]): + url = f"https://{name}-{idx}.{name}-svc.{ops_manager.namespace}.svc.cluster.local:9216/metrics" + assert https_endpoint_is_reachable(url, auth, tls_verify=False) + + assert_mongodb_prometheus_port_exist(name + "-svc", ops_manager.namespace, 9216) + + +def assert_mongodb_prometheus_port_exist(service_name: str, namespace: str, port: int): + services = client.CoreV1Api().read_namespaced_service( + name=service_name, namespace=namespace + ) + assert len(services.spec.ports) == 2 + ports = ((p.name, p.port) for p in services.spec.ports) + + assert ("mongodb", 27017) in ports + assert ("prometheus", port) in ports diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_queryable_backup.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_queryable_backup.py new file mode 100644 index 000000000..0df9ef416 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_queryable_backup.py @@ -0,0 +1,566 @@ +import os +import os +import logging + +from operator import attrgetter +from typing import Optional, Dict +from pytest import mark, fixture + +from kubernetes import client +from kubetester import create_secret +from kubetester.awss3client import AwsS3Client, s3_endpoint +from kubetester.kubetester import ( + skip_if_local, + fixture as yaml_fixture, + KubernetesTester, + running_locally, +) +from kubetester.mongodb import Phase, MongoDB +from kubetester.mongodb_user import MongoDBUser +from kubetester.om_queryable_backups import generate_queryable_pem +from kubetester.opsmanager import MongoDBOpsManager +from kubetester import ( + assert_pod_container_security_context, + assert_pod_security_context, + get_default_storage_class, +) +from tests.opsmanager.conftest import ensure_ent_version + +CREATE_RESOURCES = True +skip_if_not_create = mark.skipif(not CREATE_RESOURCES, reason="Only run when CREATE_RESOURCES") + +TEST_DB = "testdb" +TEST_COLLECTION = "testcollection" +TEST_DATA = {"name": "John", "address": "Highway 37", "age": 30} +PROJECT_NAME = "firstProject" + +LOGLEVEL = os.environ.get("LOGLEVEL", "DEBUG").upper() +logging.basicConfig(level=LOGLEVEL) + +HEAD_PATH = "/head/" +S3_SECRET_NAME = "my-s3-secret" +AWS_REGION = "us-east-1" +OPLOG_RS_NAME = "my-mongodb-oplog" +S3_RS_NAME = "my-mongodb-s3" +BLOCKSTORE_RS_NAME = "my-mongodb-blockstore" +USER_PASSWORD = "/qwerty@!#:" + + +""" +Current test focuses on backup capabilities. It creates an explicit MDBs for S3 snapshot metadata, Blockstore and Oplog +databases. Tests backup enabled for both MDB 4.0 and 4.2, snapshots created +""" + + +@fixture(scope="module") +def queryable_pem_secret(namespace: str) -> str: + return create_secret( + namespace, + "queryable-bkp-pem", + {"queryable.pem": generate_queryable_pem(namespace)}, + ) + + +def new_om_s3_store( + mdb: MongoDB, + s3_id: str, + s3_bucket_name: str, + aws_s3_client: AwsS3Client, + assignment_enabled: bool = True, + path_style_access_enabled: bool = True, + user_name: Optional[str] = None, + password: Optional[str] = None, +) -> Dict: + return { + "uri": mdb.mongo_uri(user_name=user_name, password=password), + "id": s3_id, + "pathStyleAccessEnabled": path_style_access_enabled, + "s3BucketEndpoint": s3_endpoint(AWS_REGION), + "s3BucketName": s3_bucket_name, + "awsAccessKey": aws_s3_client.aws_access_key, + "awsSecretKey": aws_s3_client.aws_secret_access_key, + "assignmentEnabled": assignment_enabled, + } + + +def new_om_data_store( + mdb: MongoDB, + id: str, + assignment_enabled: bool = True, + user_name: Optional[str] = None, + password: Optional[str] = None, +) -> Dict: + return { + "id": id, + "uri": mdb.mongo_uri(user_name=user_name, password=password), + "ssl": mdb.is_tls_enabled(), + "assignmentEnabled": assignment_enabled, + } + + +@fixture(scope="module") +def s3_bucket(aws_s3_client: AwsS3Client, namespace: str) -> str: + if CREATE_RESOURCES: + create_aws_secret(aws_s3_client, S3_SECRET_NAME, namespace) + yield from create_s3_bucket(aws_s3_client) + + +def create_aws_secret(aws_s3_client, secret_name: str, namespace: str): + KubernetesTester.create_secret( + namespace, + secret_name, + { + "accessKey": aws_s3_client.aws_access_key, + "secretKey": aws_s3_client.aws_secret_access_key, + }, + ) + print("\nCreated a secret for S3 credentials", secret_name) + + +def create_s3_bucket(aws_s3_client, bucket_prefix: str = "test-bucket-"): + """creates a s3 bucket and a s3 config""" + bucket_prefix = KubernetesTester.random_k8s_name(bucket_prefix) + if CREATE_RESOURCES: + aws_s3_client.create_s3_bucket(bucket_prefix) + print("Created S3 bucket", bucket_prefix) + + yield bucket_prefix + print("\nRemoving S3 bucket", bucket_prefix) + aws_s3_client.delete_s3_bucket(bucket_prefix) + + +@fixture(scope="module") +def ops_manager( + namespace: str, + s3_bucket: str, + custom_version: Optional[str], + custom_appdb_version: str, + queryable_pem_secret: str, +) -> MongoDBOpsManager: + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_backup.yaml"), namespace=namespace + ) + + resource["spec"]["backup"]["s3Stores"][0]["s3BucketName"] = s3_bucket + resource["spec"]["backup"]["headDB"]["storageClass"] = get_default_storage_class() + resource["spec"]["backup"]["members"] = 1 + resource["spec"]["backup"]["queryableBackupSecretRef"] = {"name": queryable_pem_secret} + + resource.set_version(custom_version) + resource.set_appdb_version(custom_appdb_version) + resource.allow_mdb_rc_versions() + + resource["spec"]["configuration"]["mongodb.release.autoDownload.enterprise"] = "true" + + if CREATE_RESOURCES: + yield resource.create() + else: + yield resource.load() + + +@fixture(scope="module") +def oplog_replica_set(ops_manager, namespace, custom_mdb_version: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=OPLOG_RS_NAME, + ).configure(ops_manager, "development") + resource["spec"]["version"] = custom_mdb_version + + # TODO: Remove when CLOUDP-60443 is fixed + # This test will update oplog to have SCRAM enabled + # Currently this results in OM failure when enabling backup for a project, backup seems to do some caching resulting in the + # mongoURI not being updated unless pod is killed. This is documented in CLOUDP-60443, once resolved this skip & comment can be deleted + resource["spec"]["security"] = {"authentication": {"enabled": True, "modes": ["SCRAM"]}} + + if CREATE_RESOURCES: + yield resource.create() + else: + yield resource.load() + + +@fixture(scope="module") +def s3_replica_set(ops_manager, namespace) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=S3_RS_NAME, + ).configure(ops_manager, "s3metadata") + + if CREATE_RESOURCES: + yield resource.create() + else: + yield resource.load() + + +@fixture(scope="module") +def blockstore_replica_set(ops_manager, namespace, custom_mdb_version: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=BLOCKSTORE_RS_NAME, + ).configure(ops_manager, "blockstore") + + resource["spec"]["version"] = custom_mdb_version + if CREATE_RESOURCES: + yield resource.create() + else: + yield resource.load() + + +@fixture(scope="module") +def blockstore_user(namespace, blockstore_replica_set: MongoDB) -> MongoDBUser: + """Creates a password secret and then the user referencing it""" + resource = MongoDBUser.from_yaml(yaml_fixture("scram-sha-user-backing-db.yaml"), namespace=namespace) + resource["spec"]["mongodbResourceRef"]["name"] = blockstore_replica_set.name + + if CREATE_RESOURCES: + print(f"\nCreating password for MongoDBUser {resource.name} in secret/{resource.get_secret_name()} ") + KubernetesTester.create_secret( + KubernetesTester.get_namespace(), + resource.get_secret_name(), + { + "password": USER_PASSWORD, + }, + ) + + if CREATE_RESOURCES: + yield resource.create() + else: + yield resource.load() + + +@fixture(scope="module") +def oplog_user(namespace, oplog_replica_set: MongoDB) -> MongoDBUser: + """Creates a password secret and then the user referencing it""" + resource = MongoDBUser.from_yaml( + yaml_fixture("scram-sha-user-backing-db.yaml"), + namespace=namespace, + name="mms-user-2", + ) + resource["spec"]["mongodbResourceRef"]["name"] = oplog_replica_set.name + resource["spec"]["passwordSecretKeyRef"]["name"] = "mms-user-2-password" + resource["spec"]["username"] = "mms-user-2" + + if CREATE_RESOURCES: + print(f"\nCreating password for MongoDBUser {resource.name} in secret/{resource.get_secret_name()} ") + KubernetesTester.create_secret( + KubernetesTester.get_namespace(), + resource.get_secret_name(), + { + "password": USER_PASSWORD, + }, + ) + + if CREATE_RESOURCES: + yield resource.create() + else: + yield resource.load() + + +@fixture(scope="module") +def mdb42(ops_manager: MongoDBOpsManager, namespace, custom_mdb_version: str): + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name="mdb-four-two", + ).configure(ops_manager, PROJECT_NAME) + resource["spec"]["version"] = ensure_ent_version(custom_mdb_version) + resource.configure_backup(mode="enabled") + if CREATE_RESOURCES: + return resource.create() + else: + return resource.load() + + +@fixture(scope="module") +def mdb_test_collection(mdb42: MongoDB): + dbClient = mdb42.tester().client[TEST_DB] + return dbClient[TEST_COLLECTION] + + +@mark.e2e_om_ops_manager_queryable_backup +@skip_if_not_create +class TestOpsManagerCreation: + """ + name: Ops Manager successful creation with backup and oplog stores enabled + description: | + Creates an Ops Manager instance with backup enabled. The OM is expected to get to 'Pending' state + eventually as it will wait for oplog db to be created + """ + + def test_setup_gp2_storage_class(self): + """This is necessary for Backup HeadDB""" + KubernetesTester.make_default_gp2_storage_class() + + def test_create_om(self, ops_manager: MongoDBOpsManager): + """creates a s3 bucket, s3 config and an OM resource (waits until Backup gets to Pending state)""" + ops_manager.backup_status().assert_reaches_phase( + Phase.Pending, + msg_regexp="The MongoDB object .+ doesn't exist", + timeout=1200, + ) + + def test_daemon_statefulset(self, ops_manager: MongoDBOpsManager): + def stateful_set_becomes_ready(): + stateful_set = ops_manager.read_backup_statefulset() + return stateful_set.status.ready_replicas == 1 and stateful_set.status.current_replicas == 1 + + KubernetesTester.wait_until(stateful_set_becomes_ready, timeout=300) + + stateful_set = ops_manager.read_backup_statefulset() + # pod template has volume mount request + assert (HEAD_PATH, "head") in ( + (mount.mount_path, mount.name) for mount in stateful_set.spec.template.spec.containers[0].volume_mounts + ) + + def test_daemon_pvc(self, ops_manager: MongoDBOpsManager, namespace: str): + """Verifies the PVCs mounted to the pod""" + pods = ops_manager.read_backup_pods() + idx = 0 + for pod in pods: + claims = [volume for volume in pod.spec.volumes if getattr(volume, "persistent_volume_claim")] + assert len(claims) == 1 + claims.sort(key=attrgetter("name")) + + default_sc = get_default_storage_class() + KubernetesTester.check_single_pvc( + namespace, + claims[0], + "head", + "head-{}-{}".format(ops_manager.backup_daemon_name(), idx), + "500M", + default_sc, + ) + idx += 1 + + def test_backup_daemon_services_created(self, namespace): + """Backup creates two additional services for queryable backup""" + services = client.CoreV1Api().list_namespaced_service(namespace).items + + # If running locally in 'default' namespace, there might be more + # services on it. Let's make sure we only count those that we care of. + # For now we allow this test to fail, because it is too broad to be significant + # and it is easy to break it. + backup_services = [s for s in services if s.metadata.name.startswith("om-backup")] + + assert len(backup_services) >= 3 + + @skip_if_local + def test_om(self, ops_manager: MongoDBOpsManager): + om_tester = ops_manager.get_om_tester() + om_tester.assert_healthiness() + for pod_fqdn in ops_manager.backup_daemon_pods_fqdns(): + om_tester.assert_daemon_enabled(pod_fqdn, HEAD_PATH) + # No oplog stores were created in Ops Manager by this time + om_tester.assert_oplog_stores([]) + om_tester.assert_s3_stores([]) + + def test_generations(self, ops_manager: MongoDBOpsManager): + assert ops_manager.appdb_status().get_observed_generation() == 1 + assert ops_manager.om_status().get_observed_generation() == 1 + assert ops_manager.backup_status().get_observed_generation() == 1 + + def test_security_contexts_appdb( + self, + ops_manager: MongoDBOpsManager, + operator_installation_config: Dict[str, str], + ): + managed = operator_installation_config["managedSecurityContext"] == "true" + for pod in ops_manager.read_appdb_pods(): + assert_pod_security_context(pod, managed) + assert_pod_container_security_context(pod.spec.containers[0], managed) + + def test_security_contexts_om( + self, + ops_manager: MongoDBOpsManager, + operator_installation_config: Dict[str, str], + ): + managed = operator_installation_config["managedSecurityContext"] == "true" + for pod in ops_manager.read_om_pods(): + assert_pod_security_context(pod, managed) + assert_pod_container_security_context(pod.spec.containers[0], managed) + + +@mark.e2e_om_ops_manager_queryable_backup +@skip_if_not_create +class TestBackupDatabasesAdded: + """name: Creates three mongodb resources for oplog, s3 and blockstore and waits until OM resource gets to + running state""" + + def test_backup_mdbs_created( + self, + oplog_replica_set: MongoDB, + s3_replica_set: MongoDB, + blockstore_replica_set: MongoDB, + oplog_user: MongoDBUser, + ): + """Creates mongodb databases all at once""" + oplog_replica_set.load() + oplog_replica_set["spec"]["security"] = {"authentication": {"enabled": True, "modes": ["SCRAM"]}} + oplog_replica_set.update() + oplog_replica_set.assert_reaches_phase(Phase.Running) + s3_replica_set.assert_reaches_phase(Phase.Running) + blockstore_replica_set.assert_reaches_phase(Phase.Running) + oplog_user.assert_reaches_phase(Phase.Updated) + + def test_fix_om(self, ops_manager: MongoDBOpsManager, oplog_user: MongoDBUser): + ops_manager.load() + ops_manager["spec"]["backup"]["opLogStores"][0]["mongodbUserRef"] = {"name": oplog_user.name} + ops_manager.update() + + ops_manager.backup_status().assert_reaches_phase( + Phase.Running, + timeout=200, + ignore_errors=True, + ) + + assert ops_manager.backup_status().get_message() is None + + @skip_if_local + def test_om( + self, + s3_bucket: str, + aws_s3_client: AwsS3Client, + ops_manager: MongoDBOpsManager, + oplog_replica_set: MongoDB, + s3_replica_set: MongoDB, + blockstore_replica_set: MongoDB, + oplog_user: MongoDBUser, + ): + om_tester = ops_manager.get_om_tester() + om_tester.assert_healthiness() + # Nothing has changed for daemon + + for pod_fqdn in ops_manager.backup_daemon_pods_fqdns(): + om_tester.assert_daemon_enabled(pod_fqdn, HEAD_PATH) + + om_tester.assert_block_stores([new_om_data_store(blockstore_replica_set, "blockStore1")]) + # oplog store has authentication enabled + om_tester.assert_oplog_stores( + [ + new_om_data_store( + oplog_replica_set, + "oplog1", + user_name=oplog_user.get_user_name(), + password=USER_PASSWORD, + ) + ] + ) + om_tester.assert_s3_stores([new_om_s3_store(s3_replica_set, "s3Store1", s3_bucket, aws_s3_client)]) + + def test_generations(self, ops_manager: MongoDBOpsManager): + """There have been an update to the OM spec - all observed generations are expected to be updated""" + assert ops_manager.appdb_status().get_observed_generation() == 2 + assert ops_manager.om_status().get_observed_generation() == 2 + assert ops_manager.backup_status().get_observed_generation() == 2 + + def test_security_contexts_backup( + self, + ops_manager: MongoDBOpsManager, + operator_installation_config: Dict[str, str], + ): + managed = operator_installation_config["managedSecurityContext"] == "true" + pods = ops_manager.read_backup_pods() + for pod in pods: + assert_pod_security_context(pod, managed) + assert_pod_container_security_context(pod.spec.containers[0], managed) + + +@mark.e2e_om_ops_manager_queryable_backup +@skip_if_not_create +class TestOpsManagerWatchesBlockStoreUpdates: + def test_om_running(self, ops_manager: MongoDBOpsManager): + ops_manager.backup_status().assert_reaches_phase(Phase.Running, timeout=40) + + def test_scramsha_enabled_for_blockstore(self, blockstore_replica_set: MongoDB, blockstore_user: MongoDBUser): + """Enables SCRAM for the blockstore replica set. Note that until CLOUDP-67736 is fixed + the order of operations (scram first, MongoDBUser - next) is important""" + blockstore_replica_set["spec"]["security"] = {"authentication": {"enabled": True, "modes": ["SCRAM"]}} + blockstore_replica_set.update() + + # timeout of 600 is required when enabling SCRAM in mdb5.0.0 + blockstore_replica_set.assert_reaches_phase(Phase.Running, timeout=900) + blockstore_user.assert_reaches_phase(Phase.Updated) + + def test_fix_om(self, ops_manager: MongoDBOpsManager, blockstore_user: MongoDBUser): + ops_manager.load() + ops_manager["spec"]["backup"]["blockStores"][0]["mongodbUserRef"] = {"name": blockstore_user.name} + ops_manager.update() + ops_manager.backup_status().assert_reaches_phase( + Phase.Running, + timeout=200, + ignore_errors=True, + ) + assert ops_manager.backup_status().get_message() is None + + @skip_if_local + def test_om( + self, + s3_bucket: str, + aws_s3_client: AwsS3Client, + ops_manager: MongoDBOpsManager, + oplog_replica_set: MongoDB, + s3_replica_set: MongoDB, + blockstore_replica_set: MongoDB, + oplog_user: MongoDBUser, + blockstore_user: MongoDBUser, + ): + om_tester = ops_manager.get_om_tester() + om_tester.assert_healthiness() + # Nothing has changed for daemon + for pod_fqdn in ops_manager.backup_daemon_pods_fqdns(): + om_tester.assert_daemon_enabled(pod_fqdn, HEAD_PATH) + + # block store has authentication enabled + om_tester.assert_block_stores( + [ + new_om_data_store( + blockstore_replica_set, + "blockStore1", + user_name=blockstore_user.get_user_name(), + password=USER_PASSWORD, + ) + ] + ) + + # oplog has authentication enabled + om_tester.assert_oplog_stores( + [ + new_om_data_store( + oplog_replica_set, + "oplog1", + user_name=oplog_user.get_user_name(), + password=USER_PASSWORD, + ) + ] + ) + om_tester.assert_s3_stores([new_om_s3_store(s3_replica_set, "s3Store1", s3_bucket, aws_s3_client)]) + + +@mark.e2e_om_ops_manager_queryable_backup +class TestBackupForMongodb: + """This part ensures that backup for the client works correctly and the snapshot is created. + Both latest and the one before the latest are tested (as the backup process for them may differ significantly)""" + + def test_mdbs_created(self, mdb42: MongoDB): + mdb42.assert_reaches_phase(Phase.Running) + + def test_add_test_data(self, mdb_test_collection): + mdb_test_collection.insert_one(TEST_DATA) + + def test_mdbs_backuped(self, ops_manager: MongoDBOpsManager): + om_tester = ops_manager.get_om_tester(project_name=PROJECT_NAME) + # wait until a first snapshot is ready + om_tester.wait_until_backup_snapshots_are_ready(expected_count=1) + + +@mark.e2e_om_ops_manager_queryable_backup +class TestQueryableBackup: + """This part queryable backup is enabled and we can query the first snapshot.""" + + def test_queryable_backup(self, ops_manager: MongoDBOpsManager): + CONNECTION_TIMEOUT = 300 + om_tester = ops_manager.get_om_tester(project_name=PROJECT_NAME) + records = om_tester.query_backup(TEST_DB, TEST_COLLECTION, CONNECTION_TIMEOUT) + assert len(records) > 0 diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_scale.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_scale.py new file mode 100644 index 000000000..327f7becd --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_scale.py @@ -0,0 +1,204 @@ +import pytest +from kubernetes import client + +from kubetester.kubetester import ( + skip_if_local, + fixture as yaml_fixture, +) +from kubetester.mongodb import Phase +from kubetester.omtester import OMBackgroundTester +from kubetester.opsmanager import MongoDBOpsManager +from pytest import fixture + +gen_key_resource_version = None +admin_key_resource_version = None +OM5_CURRENT_VERSION = "5.0.9" + +# Note the strategy for Ops Manager testing: the tests should have more than 1 updates - this is because the initial +# creation of Ops Manager takes too long, so we try to avoid fine-grained test cases and combine different +# updates in one test + +# Current test should contain all kinds of scale operations to Ops Manager as a sequence of tests + + +@fixture(scope="module") +def ops_manager(namespace, custom_version: str, custom_appdb_version: str) -> MongoDBOpsManager: + resource = MongoDBOpsManager.from_yaml(yaml_fixture("om_ops_manager_scale.yaml"), namespace=namespace) + if custom_version.startswith("6"): + resource.set_version(OM5_CURRENT_VERSION) + resource.set_appdb_version(custom_appdb_version) + return resource.create() + + +@fixture(scope="module") +def background_tester(ops_manager: MongoDBOpsManager) -> OMBackgroundTester: + om_background_tester = OMBackgroundTester(ops_manager.get_om_tester()) + om_background_tester.start() + return om_background_tester + + +@pytest.mark.e2e_om_ops_manager_scale +class TestOpsManagerCreation: + """ + Creates an Ops Manager resource of size 2. There are many configuration options passed to the OM created - + which allows to bypass the welcome wizard (see conf-hosted-mms-public-template.properties in mms) and get OM + ready for use + TODO we need to create a MongoDB resource referencing the OM and check that everything is working during scaling + operations + """ + + def test_create_om(self, ops_manager: MongoDBOpsManager): + # Backup is not fully configured so we wait until Pending phase + ops_manager.backup_status().assert_reaches_phase( + Phase.Pending, + timeout=900, + msg_regexp="Oplog Store configuration is required for backup.*", + ) + + def test_number_of_replicas(self, ops_manager: MongoDBOpsManager): + statefulset = ops_manager.read_statefulset() + assert statefulset.status.ready_replicas == 2 + assert statefulset.status.current_replicas == 2 + + def test_service(self, ops_manager: MongoDBOpsManager): + internal, external = ops_manager.services() + assert external is None + assert internal.spec.type == "ClusterIP" + assert internal.spec.cluster_ip == "None" + assert len(internal.spec.ports) == 2 + assert internal.spec.ports[0].target_port == 8080 + assert internal.spec.ports[1].target_port == 25999 + + def test_endpoints(self, ops_manager: MongoDBOpsManager): + """making sure the service points at correct pods""" + endpoints = client.CoreV1Api().read_namespaced_endpoints(ops_manager.svc_name(), ops_manager.namespace) + assert len(endpoints.subsets) == 1 + assert len(endpoints.subsets[0].addresses) == 2 + + def test_om_resource(self, ops_manager: MongoDBOpsManager): + assert ops_manager.om_status().get_replicas() == 2 + assert ops_manager.om_status().get_url() == "http://om-scale-svc.{}.svc.cluster.local:8080".format( + ops_manager.namespace + ) + + def test_backup_pod(self, ops_manager: MongoDBOpsManager): + """If spec.backup is not specified the backup statefulset is still expected to be created. + Also the number of replicas doesn't depend on OM replicas. The backup daemon pod will become + ready when the web server becomes available. + """ + ops_manager.wait_until_backup_pods_become_ready() + + @skip_if_local + def test_om(self, ops_manager: MongoDBOpsManager): + """Checks that the OM is responsive and test service is available (enabled by 'mms.testUtil.enabled').""" + om_tester = ops_manager.get_om_tester() + om_tester.assert_healthiness() + om_tester.assert_test_service() + + # checking connectivity to each OM instance + om_tester.assert_om_instances_healthiness(ops_manager.pod_urls()) + + +@pytest.mark.e2e_om_ops_manager_scale +class TestOpsManagerVersionUpgrade: + """ + The OM version is upgraded - this means the new image is deployed for both OM, appdb and backup. + The OM upgrade happens in rolling manner, we are checking for OM healthiness in parallel + """ + + def test_upgrade_om( + self, + ops_manager: MongoDBOpsManager, + background_tester: OMBackgroundTester, + custom_version: str, + ): + # Adding fixture just to start background tester + _ = background_tester + ops_manager.load() + + # If running OM6 tests, this will update from 5.0.9 to latest OM6 + ops_manager.set_version(custom_version) + + ops_manager.update() + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=900) + + def test_image_url(self, ops_manager: MongoDBOpsManager): + """All pods in statefulset are referencing the correct image""" + pods = ops_manager.read_om_pods() + assert len(pods) == 2 + assert ops_manager.get_version() in pods[0].spec.containers[0].image + assert ops_manager.get_version() in pods[1].spec.containers[0].image + + @skip_if_local + def test_om_has_been_up_during_upgrade(self, background_tester: OMBackgroundTester): + + # 10% of the requests are allowed to fail + background_tester.assert_healthiness(allowed_rate_of_failure=0.1) + + +@pytest.mark.e2e_om_ops_manager_scale +class TestOpsManagerScaleUp: + """ + The OM statefulset is scaled to 3 nodes + """ + + def test_scale_up_om(self, ops_manager: MongoDBOpsManager): + ops_manager.load() + ops_manager["spec"]["replicas"] = 3 + ops_manager.update() + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=500) + + def test_number_of_replicas(self, ops_manager: MongoDBOpsManager): + statefulset = ops_manager.read_statefulset() + assert statefulset.status.ready_replicas == 3 + assert statefulset.status.current_replicas == 3 + + assert ops_manager.om_status().get_replicas() == 3 + + @skip_if_local + def test_om(self, ops_manager: MongoDBOpsManager, background_tester: OMBackgroundTester): + """Checks that the OM is responsive and test service is available (enabled by 'mms.testUtil.enabled').""" + om_tester = ops_manager.get_om_tester() + om_tester.assert_healthiness() + om_tester.assert_test_service() + + # checking connectivity to each OM instance + om_tester.assert_om_instances_healthiness(ops_manager.pod_urls()) + + # checking the background thread to make sure the OM was ok during scale up + background_tester.assert_healthiness(allowed_rate_of_failure=0.1) + + +@pytest.mark.e2e_om_ops_manager_scale +class TestOpsManagerScaleDown: + """ + The OM resource is scaled to 1 node. This is expected to be quite fast and not availability. + TODO somehow we need to check that termination for OM pods happened successfully: CLOUDP-52310 + """ + + def test_scale_down_om(self, ops_manager: MongoDBOpsManager): + ops_manager.load() + ops_manager["spec"]["replicas"] = 1 + ops_manager.update() + ops_manager.om_status().assert_abandons_phase(Phase.Running) + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=500) + + def test_number_of_replicas(self, ops_manager: MongoDBOpsManager): + statefulset = ops_manager.read_statefulset() + assert statefulset.status.ready_replicas == 1 + assert statefulset.status.current_replicas == 1 + + # number of replicas in OM cr has changed as well + assert ops_manager.om_status().get_replicas() == 1 + + @skip_if_local + def test_om(self, ops_manager: MongoDBOpsManager, background_tester: OMBackgroundTester): + """Checks that the OM is responsive""" + om_tester = ops_manager.get_om_tester() + om_tester.assert_healthiness() + + # checking connectivity to a single pod + om_tester.assert_om_instances_healthiness(ops_manager.pod_urls()) + + # OM was ok during scale down + background_tester.assert_healthiness(allowed_rate_of_failure=0.1) diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_update_before_reconciliation.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_update_before_reconciliation.py new file mode 100644 index 000000000..2a652cb8b --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_update_before_reconciliation.py @@ -0,0 +1,36 @@ +from typing import Optional + +import pytest +import time +from kubetester.kubetester import fixture as yaml_fixture, KubernetesTester +from kubetester.mongodb import Phase +from kubetester.opsmanager import MongoDBOpsManager +from pytest import fixture +from datetime import datetime + + +@fixture(scope="module") +def ops_manager( + namespace: str, custom_version: Optional[str], custom_appdb_version: str +) -> MongoDBOpsManager: + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_basic.yaml"), namespace=namespace + ) + resource.set_version(custom_version) + resource.set_appdb_version(custom_appdb_version) + + return resource.create() + + +@pytest.mark.e2e_om_update_before_reconciliation +def test_create_om(ops_manager: MongoDBOpsManager, custom_appdb_version: str): + time.sleep(30) + + ops_manager.load() + ops_manager["spec"]["applicationDatabase"][ + "featureCompatibilityVersion" + ] = ".".join(custom_appdb_version.split(".")[:2]) + ops_manager.update() + + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=600) + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=300) diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_upgrade.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_upgrade.py new file mode 100644 index 000000000..3fe75507c --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_upgrade.py @@ -0,0 +1,424 @@ +from time import sleep +from typing import Optional + +import pytest +import semver +from kubernetes import client +from kubernetes.client.rest import ApiException +from kubetester import MongoDB, create_or_update +from kubetester.kubetester import fixture as yaml_fixture, run_periodically +from kubetester.kubetester import skip_if_local +from kubetester.mongodb import Phase +from kubetester.opsmanager import MongoDBOpsManager +from kubetester.awss3client import AwsS3Client +from pytest import fixture +from tests.opsmanager.om_appdb_scram import OM_USER_NAME +from tests.opsmanager.conftest import ensure_ent_version +from tests.opsmanager.om_ops_manager_backup import ( + HEAD_PATH, + OPLOG_RS_NAME, + new_om_data_store, + create_aws_secret, + S3_SECRET_NAME, + create_s3_bucket, +) + +# Current test focuses on Ops Manager upgrade which involves upgrade for both OpsManager and AppDB. +# MongoDBs are also upgraded. In case of minor OM version upgrade (5.x -> 6.x) agents are expected to be upgraded +# for the existing MongoDBs. + + +@fixture(scope="module") +def s3_bucket(aws_s3_client: AwsS3Client, namespace: str) -> str: + create_aws_secret(aws_s3_client, S3_SECRET_NAME, namespace) + yield from create_s3_bucket(aws_s3_client) + + +@fixture(scope="module") +def ops_manager( + namespace: str, + s3_bucket: str, + custom_om_prev_version: str, + custom_mdb_prev_version: str, +) -> MongoDBOpsManager: + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_upgrade.yaml"), namespace=namespace + ) + resource.allow_mdb_rc_versions() + resource.set_version(custom_om_prev_version) + resource.set_appdb_version(ensure_ent_version(custom_mdb_prev_version)) + resource["spec"]["backup"]["s3Stores"][0]["s3BucketName"] = s3_bucket + + return create_or_update(resource) + + +@fixture(scope="module") +def oplog_replica_set(ops_manager, namespace, custom_mdb_prev_version: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=OPLOG_RS_NAME, + ).configure(ops_manager, "development-oplog") + resource["spec"]["version"] = custom_mdb_prev_version + + return resource.create() + + +@fixture(scope="module") +def mdb(ops_manager: MongoDBOpsManager, custom_mdb_prev_version: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=ops_manager.namespace, + name="my-replica-set", + ) + resource["spec"]["version"] = custom_mdb_prev_version + resource.configure(ops_manager, "development") + return resource.create() + + +@pytest.mark.e2e_om_ops_manager_upgrade +class TestOpsManagerCreation: + """ + Creates an Ops Manager instance with AppDB of size 3. + """ + + def test_create_om(self, ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=1200) + # Monitoring + ops_manager.appdb_status().assert_abandons_phase(Phase.Running, timeout=50) + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=600) + + def test_gen_key_secret(self, ops_manager: MongoDBOpsManager): + secret = ops_manager.read_gen_key_secret() + data = secret.data + assert "gen.key" in data + + def test_admin_key_secret(self, ops_manager: MongoDBOpsManager): + secret = ops_manager.read_api_key_secret() + data = secret.data + assert "publicKey" in data + assert "privateKey" in data + + @skip_if_local + def test_om(self, ops_manager: MongoDBOpsManager, custom_om_prev_version: str): + """Checks that the OM is responsive and test service is available (enabled by 'mms.testUtil.enabled').""" + om_tester = ops_manager.get_om_tester() + om_tester.assert_healthiness() + om_tester.assert_version(custom_om_prev_version) + + om_tester.assert_test_service() + try: + om_tester.assert_support_page_enabled() + pytest.xfail("mms.helpAndSupportPage.enabled is expected to be false") + except AssertionError: + pass + + def test_appdb_scram_sha(self, ops_manager: MongoDBOpsManager): + auto_generated_password = ops_manager.read_appdb_generated_password() + automation_config_tester = ops_manager.get_automation_config_tester() + automation_config_tester.assert_authentication_mechanism_enabled( + "MONGODB-CR", False + ) + automation_config_tester.assert_authentication_mechanism_enabled( + "SCRAM-SHA-256", False + ) + ops_manager.get_appdb_tester().assert_scram_sha_authentication( + OM_USER_NAME, auto_generated_password, auth_mechanism="SCRAM-SHA-1" + ) + ops_manager.get_appdb_tester().assert_scram_sha_authentication( + OM_USER_NAME, auto_generated_password, auth_mechanism="SCRAM-SHA-256" + ) + + def test_generations(self, ops_manager: MongoDBOpsManager): + ops_manager.reload() + + assert ops_manager.appdb_status().get_observed_generation() == 1 + assert ops_manager.om_status().get_observed_generation() == 1 + assert ops_manager.backup_status().get_observed_generation() == 1 + + +@pytest.mark.e2e_om_ops_manager_upgrade +class TestBackupCreation: + def test_oplog_mdb_created( + self, + oplog_replica_set: MongoDB, + ): + oplog_replica_set.assert_reaches_phase(Phase.Running) + + def test_add_oplog_config(self, ops_manager: MongoDBOpsManager): + ops_manager.load() + ops_manager["spec"]["backup"]["opLogStores"] = [ + {"name": "oplog1", "mongodbResourceRef": {"name": "my-mongodb-oplog"}} + ] + ops_manager.update() + + def test_backup_is_enabled(self, ops_manager: MongoDBOpsManager): + ops_manager.backup_status().assert_reaches_phase( + Phase.Running, + timeout=500, + ignore_errors=True, + ) + + def test_generations(self, ops_manager: MongoDBOpsManager): + ops_manager.reload() + + assert ops_manager.appdb_status().get_observed_generation() == 2 + assert ops_manager.om_status().get_observed_generation() == 2 + assert ops_manager.backup_status().get_observed_generation() == 2 + + +@pytest.mark.e2e_om_ops_manager_upgrade +class TestOpsManagerWithMongoDB: + def test_mongodb_create(self, mdb: MongoDB, custom_mdb_prev_version: str): + mdb.assert_reaches_phase(Phase.Running, timeout=350) + mdb.assert_connectivity() + mdb.tester().assert_version(custom_mdb_prev_version) + + def test_mongodb_upgrade(self, mdb: MongoDB): + """Scales up the mongodb. Note, that we are not upgrading the Mongodb version at this stage as it can be + the major update (e.g. 4.2 -> 4.4) and this requires OM upgrade as well - this happens later.""" + mdb.reload() + mdb["spec"]["members"] = 4 + + mdb.update() + mdb.assert_reaches_phase(Phase.Running, timeout=900) + mdb.assert_connectivity() + assert mdb.get_status_members() == 4 + + +@pytest.mark.e2e_om_ops_manager_upgrade +class TestOpsManagerConfigurationChange: + """ + The OM configuration changes: one property is removed, another is added. + Note, that this is quite artificial change to make it testable, these properties affect the behavior of different + endpoints in Ops Manager, so we can then check if the changes were propagated to OM + """ + + def test_restart_om(self, ops_manager: MongoDBOpsManager): + ops_manager.load() + ops_manager["spec"]["configuration"]["mms.testUtil.enabled"] = "" + ops_manager["spec"]["configuration"]["mms.helpAndSupportPage.enabled"] = "true" + ops_manager.update() + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=500) + + def test_keys_not_modified( + self, + ops_manager: MongoDBOpsManager, + gen_key_resource_version: str, + admin_key_resource_version: str, + ): + """Making sure that the new reconciliation hasn't tried to generate new gen and api keys""" + gen_key_secret = ops_manager.read_gen_key_secret() + api_key_secret = ops_manager.read_api_key_secret() + + assert gen_key_secret.metadata.resource_version == gen_key_resource_version + assert api_key_secret.metadata.resource_version == admin_key_resource_version + + @skip_if_local + def test_om(self, ops_manager: MongoDBOpsManager): + """Checks that the OM is responsive and test service is not available""" + om_tester = ops_manager.get_om_tester() + om_tester.assert_healthiness() + om_tester.assert_support_page_enabled() + try: + om_tester.assert_test_service() + pytest.xfail("mms.testUtil.enabled is expected to be false") + except AssertionError: + pass + + def test_generations(self, ops_manager: MongoDBOpsManager): + ops_manager.reload() + + assert ops_manager.appdb_status().get_observed_generation() == 3 + assert ops_manager.om_status().get_observed_generation() == 3 + assert ops_manager.backup_status().get_observed_generation() == 3 + + +@pytest.mark.e2e_om_ops_manager_upgrade +class TestOpsManagerVersionUpgrade: + """ + The OM version is upgraded - this means the new image is deployed for both OM and appdb. + """ + + agent_version = None + + def test_agent_version(self, mdb: MongoDB): + TestOpsManagerVersionUpgrade.agent_version = ( + mdb.get_automation_config_tester().get_agent_version() + ) + + def test_upgrade_om_version( + self, + ops_manager: MongoDBOpsManager, + custom_version: Optional[str], + custom_appdb_version: str, + ): + ops_manager.load() + ops_manager.set_version(custom_version) + ops_manager.set_appdb_version(custom_appdb_version) + + ops_manager.update() + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=1200) + + def test_image_url(self, ops_manager: MongoDBOpsManager): + pods = ops_manager.read_om_pods() + assert len(pods) == 1 + assert ops_manager.get_version() in pods[0].spec.containers[0].image + + @skip_if_local + def test_om(self, ops_manager: MongoDBOpsManager): + om_tester = ops_manager.get_om_tester() + om_tester.assert_healthiness() + om_tester.assert_version(ops_manager.get_version()) + + def test_appdb(self, ops_manager: MongoDBOpsManager, custom_appdb_version: str): + mdb_tester = ops_manager.get_appdb_tester() + mdb_tester.assert_connectivity() + mdb_tester.assert_version(custom_appdb_version) + + def test_appdb_scram_sha(self, ops_manager: MongoDBOpsManager): + auto_generated_password = ops_manager.read_appdb_generated_password() + automation_config_tester = ops_manager.get_automation_config_tester() + automation_config_tester.assert_authentication_mechanism_enabled( + "MONGODB-CR", False + ) + automation_config_tester.assert_authentication_mechanism_enabled( + "SCRAM-SHA-256", False + ) + ops_manager.get_appdb_tester().assert_scram_sha_authentication( + OM_USER_NAME, auto_generated_password, auth_mechanism="SCRAM-SHA-1" + ) + ops_manager.get_appdb_tester().assert_scram_sha_authentication( + OM_USER_NAME, auto_generated_password, auth_mechanism="SCRAM-SHA-256" + ) + + +@pytest.mark.e2e_om_ops_manager_upgrade +class TestMongoDbsVersionUpgrade: + def test_mongodb_upgrade(self, mdb: MongoDB, custom_mdb_version: str): + """Ensures that the existing MongoDB works fine with the new Ops Manager (scales up one member) + Some details: + - in case of patch upgrade of OM the existing agent is guaranteed to work with the new OM - we don't require + the upgrade of all the agents + - in case of major/minor OM upgrade the agents MUST be upgraded before reconciling - so that's why the agents upgrade + is enforced before MongoDB reconciliation (the OM reconciliation happened above will drop the 'agents.nextScheduledTime' + counter) + """ + # Because OM was not in running phase, this resource, mdb, was also not in + # running phase. We will wait for it to come back before applying any changes. + mdb.reload() + # Forcing a pod restart to get the new agent version. This should be fixed in https://jira.mongodb.org/browse/CLOUDP-161473 + mdb["spec"]["podSpec"] = { + "podTemplate": {"metadata": {"annotations": {"key1": "val1"}}} + } + mdb.update() + mdb.assert_reaches_phase(Phase.Running, timeout=600, ignore_errors=True) + # At this point all the agent versions should be up to date and we can perform the database version upgrade. + + mdb["spec"]["version"] = custom_mdb_version + mdb.update() + + mdb.assert_reaches_phase(Phase.Running, timeout=1200) + mdb.assert_connectivity() + mdb.tester().assert_version(custom_mdb_version) + + def test_agents_upgraded( + self, mdb: MongoDB, ops_manager: MongoDBOpsManager, custom_om_prev_version: str + ): + """The agents were requested to get upgraded immediately after Ops Manager upgrade. + Note, that this happens only for OM major/minor upgrade, so we need to check only this case + TODO CLOUDP-64622: we need to check the periodic agents upgrade as well - this can be done through Operator custom configuration""" + prev_version = semver.VersionInfo.parse(custom_om_prev_version) + new_version = semver.VersionInfo.parse(ops_manager.get_version()) + if ( + prev_version.major != new_version.major + or prev_version.minor != new_version.minor + ): + assert ( + TestOpsManagerVersionUpgrade.agent_version + != mdb.get_automation_config_tester().get_agent_version() + ) + + +@pytest.mark.e2e_om_ops_manager_upgrade +class TestAppDBScramShaUpdated: + def test_appdb_reconcile(self, ops_manager: MongoDBOpsManager): + ops_manager.load() + ops_manager["spec"]["applicationDatabase"]["logLevel"] = "DEBUG" + ops_manager.update() + + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=400) + + @pytest.mark.skip( + reason="re-enable when only SCRAM-SHA-256 is supported for the AppDB" + ) + def test_appdb_scram_sha_(self, ops_manager: MongoDBOpsManager): + automation_config_tester = ops_manager.get_automation_config_tester() + automation_config_tester.assert_authentication_mechanism_enabled( + "SCRAM-SHA-256", False + ) + automation_config_tester.assert_authentication_mechanism_disabled( + "MONGODB-CR", False + ) + + +@pytest.mark.e2e_om_ops_manager_upgrade +class TestBackupDaemonVersionUpgrade: + def test_upgrade_backup_daemon( + self, + ops_manager: MongoDBOpsManager, + ): + ops_manager.backup_status().assert_reaches_phase( + Phase.Running, + timeout=600, + ignore_errors=True, + ) + + def test_backup_daemon_image_url( + self, + ops_manager: MongoDBOpsManager, + ): + pods = ops_manager.read_backup_pods() + assert ops_manager.get_version() in pods[0].spec.containers[0].image + + +@pytest.mark.e2e_om_ops_manager_upgrade +class TestOpsManagerRemoved: + """ + Deletes an Ops Manager Custom resource and verifies that some of the dependant objects are removed + """ + + def test_opsmanager_deleted(self, ops_manager: MongoDBOpsManager): + ops_manager.delete() + + def om_is_clean(): + try: + ops_manager.load() + return False + except ApiException: + return True + + run_periodically(om_is_clean, timeout=180) + # Some strange race conditions/caching - the api key secret is still queryable right after OM removal + # (in openshift mainly) + sleep(20) + + def test_api_key_removed(self, ops_manager: MongoDBOpsManager): + with pytest.raises(ApiException): + ops_manager.read_api_key_secret() + + def test_gen_key_not_removed( + self, ops_manager: MongoDBOpsManager, gen_key_resource_version: str + ): + """The gen key must not be removed - this is for situations when the appdb is persistent - + so PVs may survive removal""" + gen_key_secret = ops_manager.read_gen_key_secret() + assert gen_key_secret.metadata.resource_version == gen_key_resource_version + + def test_om_sts_removed(self, ops_manager: MongoDBOpsManager): + with pytest.raises(ApiException): + ops_manager.read_statefulset() + + def test_om_appdb_removed(self, ops_manager: MongoDBOpsManager): + with pytest.raises(ApiException): + ops_manager.read_appdb_statefulset() diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_weak_password.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_weak_password.py new file mode 100644 index 000000000..7e2bf99b0 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_ops_manager_weak_password.py @@ -0,0 +1,55 @@ +from typing import Optional + +import pytest +from kubetester.kubetester import fixture as yaml_fixture, KubernetesTester +from kubetester.mongodb import Phase +from kubetester.opsmanager import MongoDBOpsManager +from pytest import fixture + + +@fixture(scope="module") +def ops_manager( + namespace: str, custom_version: Optional[str], custom_appdb_version: str +) -> MongoDBOpsManager: + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_basic.yaml"), namespace=namespace + ) + resource.set_version(custom_version) + resource.set_appdb_version(custom_appdb_version) + + return resource.create() + + +@pytest.mark.e2e_om_weak_password +def test_update_secret_weak_password(ops_manager: MongoDBOpsManager): + data = KubernetesTester.read_secret( + ops_manager.namespace, ops_manager.get_admin_secret_name() + ) + data["Password"] = "weak" + KubernetesTester.update_secret( + ops_manager.namespace, ops_manager.get_admin_secret_name(), data + ) + + +@pytest.mark.e2e_om_weak_password +def test_create_om(ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_reaches_phase( + Phase.Failed, msg_regexp=".*WEAK_PASSWORD.*", timeout=900 + ) + + +@pytest.mark.e2e_om_weak_password +def test_fix_password(ops_manager: MongoDBOpsManager): + data = KubernetesTester.read_secret( + ops_manager.namespace, ops_manager.get_admin_secret_name() + ) + data["Password"] = "Passw0rd." + KubernetesTester.update_secret( + ops_manager.namespace, ops_manager.get_admin_secret_name(), data + ) + + +@pytest.mark.e2e_om_weak_password +def test_om_reaches_running(ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_abandons_phase(Phase.Failed) + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=150) diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_remotemode.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_remotemode.py new file mode 100644 index 000000000..755322eac --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_remotemode.py @@ -0,0 +1,236 @@ +from typing import Optional, Dict, Any + +import yaml +import time +from kubetester.kubetester import ( + fixture as yaml_fixture, + skip_if_local, + KubernetesTester, +) +from kubetester.mongodb import Phase, MongoDB +from kubetester.opsmanager import MongoDBOpsManager +from pytest import fixture, mark + + +VERSION_NOT_IN_WEB_SERVER = "4.2.1" + + +def add_mdb_version_to_deployment(deployment: Dict[str, Any], version: str): + """ + Adds a new initContainer to `deployment` to download a particular MongoDB version. + + Please note that the initContainers will never fail, so it is fine to add version that don't + exist for older distributions (like mdb5.0 in ubuntu1604). + """ + mount_path = "/mongodb-ops-manager/mongodb-releases/linux" + distros = ("rhel80", "ubuntu1604", "ubuntu1804") + + base_url_community = "https://fastdl.mongodb.org/linux/mongodb-linux-x86_64" + base_url_enterprise = ( + "https://downloads.mongodb.com/linux/mongodb-linux-x86_64-enterprise" + ) + base_url = base_url_community + if version.endswith("-ent"): + # If version is enterprise, the base_url changes slightly + base_url = base_url_enterprise + version = version.replace("-ent", "") + + if "initContainers" not in deployment["spec"]["template"]["spec"]: + deployment["spec"]["template"]["spec"]["initContainers"] = [] + + for distro in distros: + url = f"{base_url}-{distro}-{version}.tgz" + curl_command = f"curl -LO {url} --output-dir {mount_path}" + + container = { + "name": KubernetesTester.random_k8s_name(prefix="mdb-download"), + "image": "curlimages/curl:latest", + "command": ["sh", "-c", f"{curl_command} && true"], + "volumeMounts": [ + { + "name": "mongodb-versions", + "mountPath": mount_path, + } + ], + } + deployment["spec"]["template"]["spec"]["initContainers"].append(container) + + +@fixture(scope="module") +def nginx(namespace: str, custom_mdb_version: str, custom_appdb_version: str): + with open(yaml_fixture("remote_fixtures/nginx-config.yaml"), "r") as f: + config_body = yaml.safe_load(f.read()) + KubernetesTester.clients("corev1").create_namespaced_config_map( + namespace, config_body + ) + + with open(yaml_fixture("remote_fixtures/nginx.yaml"), "r") as f: + nginx_body = yaml.safe_load(f.read()) + + # Adds versions to Nginx deployment. + new_versions = set() + new_versions.add(custom_mdb_version) + new_versions.add(custom_mdb_version + "-ent") + new_versions.add(custom_appdb_version) + + for version in new_versions: + add_mdb_version_to_deployment(nginx_body, version) + + KubernetesTester.create_deployment(namespace, body=nginx_body) + + with open(yaml_fixture("remote_fixtures/nginx-svc.yaml"), "r") as f: + service_body = yaml.safe_load(f.read()) + KubernetesTester.create_service(namespace, body=service_body) + + yield + + KubernetesTester.delete_configmap(namespace, "nginx-conf") + KubernetesTester.delete_service(namespace, "nginx-svc") + KubernetesTester.delete_deployment(namespace, "nginx-deployment") + + +@fixture(scope="module") +def ops_manager( + namespace: str, custom_version: Optional[str], custom_appdb_version: str, nginx +) -> MongoDBOpsManager: + + """The fixture for Ops Manager to be created.""" + om: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("remote_fixtures/om_remotemode.yaml"), + namespace=namespace, + ) + om["spec"]["configuration"]["automation.versions.source"] = "remote" + om["spec"]["configuration"][ + "automation.versions.download.baseUrl" + ] = f"http://nginx-svc.{namespace}.svc.cluster.local:80" + + om.set_version(custom_version) + om.set_appdb_version(custom_appdb_version) + om.allow_mdb_rc_versions() + + yield om.create() + + +@fixture(scope="module") +def replica_set( + ops_manager: MongoDBOpsManager, namespace: str, custom_mdb_version: str +) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + ).configure(ops_manager, "my-replica-set") + resource["spec"]["version"] = custom_mdb_version + yield resource.create() + + +@fixture(scope="module") +def replica_set_ent( + ops_manager: MongoDBOpsManager, namespace: str, custom_mdb_version: str +) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name="the-replica-set-ent", + ).configure(ops_manager, "my-other-replica-set") + resource["spec"]["version"] = custom_mdb_version + "-ent" + yield resource.create() + + +@mark.e2e_om_remotemode +def test_appdb(ops_manager: MongoDBOpsManager): + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=400) + assert ops_manager.appdb_status().get_members() == 3 + + +@skip_if_local +@mark.e2e_om_remotemode +def test_appdb_mongod(ops_manager: MongoDBOpsManager): + mdb_tester = ops_manager.get_appdb_tester() + mdb_tester.assert_connectivity() + + +@mark.e2e_om_remotemode +def test_ops_manager_reaches_running_phase(ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=900) + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=400) + + # CLOUDP-83792: some insight: OM has a number of Cron jobs and one of them is responsible for filtering the builds + # returned in the automation config to include only the available ones (in remote/local modes). + # Somehow though as of OM 4.4.9 this filtering didn't work fine and some Enterprise builds were not returned so + # the replica sets using enterprise versions didn't reach the goal. + # We need to sleep for some time to let the cron get into the game and this allowed to reproduce the issue + # (got fixed by switching off the cron by 'automation.versions.download.baseUrl.allowOnlyAvailableBuilds: false') + print("Sleeping for one minute to let Ops Manager Cron jobs kick in") + time.sleep(60) + + +@mark.e2e_om_remotemode +def test_replica_sets_reaches_running_phase( + replica_set: MongoDB, replica_set_ent: MongoDB +): + """Doing this in parallel for faster success""" + replica_set.assert_reaches_phase(Phase.Running, timeout=600) + replica_set_ent.assert_reaches_phase(Phase.Running, timeout=300) + + +@mark.e2e_om_remotemode +def test_replica_set_reaches_failed_phase(replica_set: MongoDB): + replica_set.load() + replica_set["spec"]["version"] = VERSION_NOT_IN_WEB_SERVER + replica_set.update() + + # ReplicaSet times out attempting to fetch version from web server + replica_set.assert_reaches_phase(Phase.Failed, timeout=200) + + +@mark.e2e_om_remotemode +def test_replica_set_recovers(replica_set: MongoDB, custom_mdb_version: str): + replica_set["spec"]["version"] = custom_mdb_version + replica_set.update() + replica_set.assert_reaches_phase(Phase.Running, timeout=600) + + +@skip_if_local +@mark.e2e_om_remotemode +def test_client_can_connect_to_mongodb(replica_set: MongoDB): + replica_set.assert_connectivity() + + +@skip_if_local +@mark.e2e_om_remotemode +def test_client_can_connect_to_mongodb_ent(replica_set_ent: MongoDB): + replica_set_ent.assert_connectivity() + + +@skip_if_local +@mark.e2e_om_remotemode +def test_client_can_connect_to_mongodb_ent(replica_set_ent: MongoDB): + replica_set_ent.assert_connectivity() + + +@mark.e2e_om_remotemode +def test_restart_ops_manager_pod(ops_manager: MongoDBOpsManager): + ops_manager.load() + ops_manager["spec"]["configuration"]["mms.testUtil.enabled"] = "false" + ops_manager.update() + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=900) + + +@mark.e2e_om_remotemode +def test_can_scale_replica_set(replica_set: MongoDB): + replica_set.load() + replica_set["spec"]["members"] = 5 + replica_set.update() + replica_set.assert_reaches_phase(Phase.Running, timeout=600) + + +@skip_if_local +@mark.e2e_om_remotemode +def test_client_can_still_connect(replica_set: MongoDB): + replica_set.assert_connectivity() + + +@skip_if_local +@mark.e2e_om_remotemode +def test_client_can_still_connect_to_ent(replica_set_ent: MongoDB): + replica_set_ent.assert_connectivity() diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/om_validation_webhook.py b/docker/mongodb-enterprise-tests/tests/opsmanager/om_validation_webhook.py new file mode 100644 index 000000000..e5c5a4bee --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/om_validation_webhook.py @@ -0,0 +1,84 @@ +""" +Ensures that validation warnings for ops manager reflect its current state +""" +from typing import Optional + +import pytest +from kubernetes.client.rest import ApiException +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.operator import Operator +from kubetester.opsmanager import MongoDBOpsManager +from pytest import fixture, mark + +APPDB_SHARD_COUNT_WARNING = "ShardCount field is not configurable for application databases as it is for sharded clusters and appdbs are replica sets" + + +@mark.e2e_om_validation_webhook +def test_wait_for_webhook(namespace: str, default_operator: Operator): + default_operator.wait_for_webhook() + + +def om_validation(namespace: str) -> MongoDBOpsManager: + return MongoDBOpsManager.from_yaml( + yaml_fixture("om_validation.yaml"), namespace=namespace + ) + + +@mark.e2e_om_validation_webhook +def test_connectivity_not_allowed_in_appdb(namespace: str): + om = om_validation(namespace) + + om["spec"]["applicationDatabase"]["connectivity"] = { + "replicaSetHorizons": [{"test-horizon": "dfdfdf"}] + } + + with pytest.raises( + ApiException, + match=r"connectivity field is not configurable for application databases", + ): + om.create() + + +@mark.e2e_om_validation_webhook +def test_opsmanager_version(namespace: str): + om = om_validation(namespace) + om["spec"]["version"] = "4.4.4.4" + + with pytest.raises(ApiException, match=r"is an invalid value for spec.version"): + om.create() + + +@mark.e2e_om_validation_webhook +def test_appdb_version(namespace: str): + om = om_validation(namespace) + om["spec"]["applicationDatabase"]["version"] = "4.4.10.10" + + # this exception is raised by CRD regexp validation for the version, not our internal one + with pytest.raises( + ApiException, match=r"spec.applicationDatabase.version in body should match" + ): + om.create() + + om["spec"]["applicationDatabase"]["version"] = "3.6.12" + with pytest.raises( + ApiException, match=r"the version of Application Database must be \\u003e= 4.0" + ): + om.create() + + +@fixture(scope="module") +def ops_manager( + namespace: str, custom_version: Optional[str], custom_appdb_version: str +) -> MongoDBOpsManager: + om: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_basic.yaml"), namespace=namespace + ) + om.set_version(custom_version) + om.set_appdb_version(custom_appdb_version) + return om.create() + + +@mark.e2e_om_validation_webhook +class TestOpsManagerValidationWarnings: + def test_disable_webhook(self, default_operator: Operator): + default_operator.disable_webhook() diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/__init__.py b/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/conftest.py b/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/conftest.py new file mode 100644 index 000000000..c438e3b18 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/conftest.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python3 + + +def pytest_runtest_setup(item): + """This allows to automatically install the Operator and enable AppDB monitoring before running any test""" + if "operator_with_monitored_appdb" not in item.fixturenames: + item.fixturenames.insert(0, "operator_with_monitored_appdb") diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_appdb_agent_flags.py b/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_appdb_agent_flags.py new file mode 100644 index 000000000..c792e8c06 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_appdb_agent_flags.py @@ -0,0 +1,175 @@ +from pytest import mark, fixture + +from kubetester import find_fixture + +from kubetester.opsmanager import MongoDBOpsManager +from kubetester.mongodb import Phase + +from kubetester.kubetester import KubernetesTester + +from typing import Optional + + +@fixture(scope="module") +def ops_manager(namespace: str, custom_version: Optional[str], custom_appdb_version: str) -> MongoDBOpsManager: + resource = MongoDBOpsManager.from_yaml( + find_fixture("om_validation.yaml"), namespace=namespace, name="om-agent-flags" + ) + + # both monitoring and automation agent should see these changes + resource["spec"]["applicationDatabase"]["agent"] = { + "startupOptions": {"logFile": "/var/log/mongodb-mms-automation/customLogFile"} + } + resource["spec"]["applicationDatabase"]["memberConfig"] = [ + { + "votes": 1, + "priority": "0.5", + "tags": { + "tag1": "value1", + "environment": "prod", + }, + }, + { + "votes": 1, + "priority": "1.5", + "tags": { + "tag2": "value2", + "environment": "prod", + }, + }, + { + "votes": 1, + "priority": "0.5", + "tags": { + "tag2": "value2", + "environment": "prod", + }, + }, + ] + resource.set_version(custom_version) + resource.set_appdb_version(custom_appdb_version) + + return resource.create() + + +@mark.e2e_om_appdb_agent_flags +def test_appdb(ops_manager: MongoDBOpsManager): + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=400) + + +@mark.e2e_om_appdb_agent_flags +def test_om(ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=800) + + +@mark.e2e_om_appdb_agent_flags +def test_monitoring_is_configured(ops_manager: MongoDBOpsManager): + ops_manager.appdb_status().assert_abandons_phase(Phase.Running, timeout=100) + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=600) + + +@mark.e2e_om_appdb_agent_flags +def test_appdb_has_agent_flags(ops_manager: MongoDBOpsManager): + cmd = [ + "/bin/sh", + "-c", + "ls /var/log/mongodb-mms-automation/customLogFile* | wc -l", + ] + for pod in ops_manager.read_appdb_pods(): + result = KubernetesTester.run_command_in_pod_container( + pod.metadata.name, ops_manager.namespace, cmd, container="mongodb-agent" + ) + assert result != "0" + + +@mark.e2e_om_appdb_agent_flags +def test_appdb_monitoring_agent_flags_inherit_automation_agent_flags( + ops_manager: MongoDBOpsManager, +): + cmd = [ + "/bin/sh", + "-c", + "ls /var/log/mongodb-mms-automation/customLogFileMonitoring* | wc -l", + ] + for pod in ops_manager.read_appdb_pods(): + result = KubernetesTester.run_command_in_pod_container( + pod.metadata.name, + ops_manager.namespace, + cmd, + container="mongodb-agent-monitoring", + ) + assert "No such file or directory" in result + + cmd = [ + "/bin/sh", + "-c", + "ls /var/log/mongodb-mms-automation/customLogFile* | wc -l", + ] + for pod in ops_manager.read_appdb_pods(): + result = KubernetesTester.run_command_in_pod_container( + pod.metadata.name, + ops_manager.namespace, + cmd, + container="mongodb-agent-monitoring", + ) + assert result != "0" + + +@mark.e2e_om_appdb_agent_flags +def test_appdb_flags_changed(ops_manager: MongoDBOpsManager): + ops_manager.load() + ops_manager["spec"]["applicationDatabase"]["agent"]["startupOptions"]["dialTimeoutSeconds"] = "70" + + ops_manager["spec"]["applicationDatabase"]["monitoringAgent"] = { + "startupOptions": { + "logFile": "/var/log/mongodb-mms-automation/customLogFileMonitoring", + "dialTimeoutSeconds": "80", + } + } + ops_manager.update() + ops_manager.appdb_status().assert_abandons_phase(Phase.Running, timeout=50) + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=600) + + +@mark.e2e_om_appdb_agent_flags +def test_appdb_has_changed_agent_flags(ops_manager: MongoDBOpsManager, namespace: str): + MMS_AUTOMATION_AGENT_PGREP = [ + "/bin/sh", + "-c", + "pgrep -f -a agent/mongodb-agent", + ] + for pod in ops_manager.read_appdb_pods(): + result = KubernetesTester.run_command_in_pod_container( + pod.metadata.name, + namespace, + MMS_AUTOMATION_AGENT_PGREP, + container="mongodb-agent", + ) + assert "-logFile=/var/log/mongodb-mms-automation/customLogFile" in result + assert "-dialTimeoutSeconds=70" in result + + result = KubernetesTester.run_command_in_pod_container( + pod.metadata.name, + namespace, + MMS_AUTOMATION_AGENT_PGREP, + container="mongodb-agent-monitoring", + ) + assert "-logFile=/var/log/mongodb-mms-automation/customLogFileMonitoring" in result + assert "-dialTimeoutSeconds=80" in result + + +@mark.e2e_om_appdb_agent_flags +def test_automation_config_secret_member_options(ops_manager: MongoDBOpsManager, namespace: str): + members = ops_manager.get_automation_config_tester().get_replica_set_members(ops_manager.app_db_name()) + + assert members[0]["votes"] == 1 + assert members[0]["priority"] == 0.5 + assert members[0]["tags"] == {"environment": "prod", "tag1": "value1"} + + assert members[1]["votes"] == 1 + assert members[1]["priority"] == 1.5 + assert members[1]["tags"] == {"environment": "prod", "tag2": "value2"} + + assert members[2]["votes"] == 1 + assert members[2]["priority"] == 0.5 + assert members[2]["tags"] == {"environment": "prod", "tag2": "value2"} diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_appdb_configure_all_images.py b/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_appdb_configure_all_images.py new file mode 100644 index 000000000..0ff119017 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_appdb_configure_all_images.py @@ -0,0 +1,85 @@ +from kubernetes.client import V1Container +from pytest import mark, fixture + +from kubetester import find_fixture +from kubetester.kubetester import ensure_nested_objects + +from kubetester.opsmanager import MongoDBOpsManager +from kubetester.mongodb import Phase + +from typing import Optional, List + +AGENT_NAME = "mongodb-agent" +MONGOD_NAME = "mongod" +MONITORING_AGENT_NAME = "mongodb-agent-monitoring" + + +@fixture(scope="module") +def ops_manager(namespace: str, custom_version: Optional[str], custom_appdb_version: str) -> MongoDBOpsManager: + resource = MongoDBOpsManager.from_yaml( + find_fixture("om_appdb_configure_all_images.yaml"), + namespace=namespace, + name="om-configure-all-appdb-images", + ) + + resource.set_version(custom_version) + resource.set_appdb_version(custom_appdb_version) + + ensure_nested_objects(resource, ["spec", "applicationDatabase", "podSpec", "podTemplate", "spec"]) + + resource["spec"]["applicationDatabase"]["version"] = "4.4.0" + + resource["spec"]["applicationDatabase"]["podSpec"]["podTemplate"]["spec"]["containers"] = [ + { + "name": AGENT_NAME, + "image": "quay.io/mongodb/mongodb-agent:10.29.0.6830-1", + }, + { + "name": MONGOD_NAME, + "image": "quay.io/mongodb/mongodb-enterprise-server:4.4.0-ubi8", + }, + { + "name": MONITORING_AGENT_NAME, + "image": "quay.io/mongodb/mongodb-agent:10.29.0.6830-1", + }, + ] + + return resource.create() + + +@mark.e2e_om_appdb_configure_all_images +def test_appdb(ops_manager: MongoDBOpsManager): + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=400) + + +@mark.e2e_om_appdb_configure_all_images +def test_om_get_started(ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=400) + ops_manager.appdb_status().assert_abandons_phase(Phase.Running, timeout=50) + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=300) + + +@mark.e2e_om_appdb_configure_all_images +def test_statefulset_spec_is_updated(ops_manager: MongoDBOpsManager): + appdb_sts = ops_manager.read_appdb_statefulset() + containers = appdb_sts.spec.template.spec.containers + assert len(containers) == 3 + + agent_container = _get_container_by_name(AGENT_NAME, containers) + + assert agent_container is not None + assert agent_container.image == "quay.io/mongodb/mongodb-agent:10.29.0.6830-1" + + mongod_container = _get_container_by_name(MONGOD_NAME, containers) + + assert mongod_container is not None + assert mongod_container.image == "quay.io/mongodb/mongodb-enterprise-server:4.4.0-ubi8" + + monitoring_container = _get_container_by_name(MONITORING_AGENT_NAME, containers) + + assert monitoring_container is not None + assert monitoring_container.image == "quay.io/mongodb/mongodb-agent:10.29.0.6830-1" + + +def _get_container_by_name(name: str, containers: List[V1Container]) -> Optional[V1Container]: + return next(filter(lambda c: c.name == name, containers)) diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_appdb_scale_up_down.py b/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_appdb_scale_up_down.py new file mode 100644 index 000000000..25c3581dd --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_appdb_scale_up_down.py @@ -0,0 +1,144 @@ +from typing import Optional + +import pytest +from kubetester.kubetester import ( + skip_if_local, + fixture as yaml_fixture, +) +from kubetester.mongodb import Phase +from kubetester.opsmanager import MongoDBOpsManager +from pytest import fixture + + +# Important - you need to ensure that OM and Appdb images are build and pushed into your current docker registry before +# running tests locally - use "make om-image" and "make appdb" to do this + + +@fixture(scope="module") +def ops_manager(namespace: str, custom_version: Optional[str], custom_appdb_version: str) -> MongoDBOpsManager: + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_appdb_scale_up_down.yaml"), namespace=namespace + ) + resource.set_version(custom_version) + resource.set_appdb_version(custom_appdb_version) + + return resource.create() + + +@pytest.mark.e2e_om_appdb_scale_up_down +class TestOpsManagerCreation: + """ + Creates an Ops Manager instance with AppDB of size 3. Note, that the initial creation usually takes ~500 seconds + """ + + def test_create_om(self, ops_manager: MongoDBOpsManager): + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=600) + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=1200) + # some more time for monitoring rolling upgrade + ops_manager.appdb_status().assert_abandons_phase(Phase.Running, timeout=100) + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=300) + + def test_gen_key_secret(self, ops_manager: MongoDBOpsManager): + secret = ops_manager.read_gen_key_secret() + data = secret.data + assert "gen.key" in data + + def test_admin_key_secret(self, ops_manager: MongoDBOpsManager): + secret = ops_manager.read_api_key_secret() + data = secret.data + assert "publicKey" in data + assert "privateKey" in data + + def test_appdb_connection_url_secret(self, ops_manager: MongoDBOpsManager): + assert len(ops_manager.read_appdb_members_from_connection_url_secret()) == 3 + + def test_appdb(self, ops_manager: MongoDBOpsManager, custom_appdb_version: str): + assert ops_manager.appdb_status().get_members() == 3 + assert ops_manager.appdb_status().get_version() == custom_appdb_version + statefulset = ops_manager.read_appdb_statefulset() + assert statefulset.status.ready_replicas == 3 + assert statefulset.status.current_replicas == 3 + + def test_appdb_monitoring_group_was_created(self, ops_manager: MongoDBOpsManager): + ops_manager.assert_appdb_monitoring_group_was_created() + + def test_admin_config_map(self, ops_manager: MongoDBOpsManager): + ops_manager.get_automation_config_tester().reached_version(1) + + @skip_if_local + def test_om_connectivity(self, ops_manager: MongoDBOpsManager): + ops_manager.get_om_tester().assert_healthiness() + # todo check the backing db group, automation config and data integrity + + +@pytest.mark.e2e_om_appdb_scale_up_down +class TestOpsManagerAppDbScaleUp: + """ + Scales appdb up to 5 members + """ + + def test_scale_app_db_up(self, ops_manager: MongoDBOpsManager): + ops_manager.load() + ops_manager["spec"]["applicationDatabase"]["members"] = 5 + ops_manager.update() + + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=600) + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=600) + + def test_keys_not_touched( + self, + ops_manager: MongoDBOpsManager, + gen_key_resource_version: str, + admin_key_resource_version: str, + ): + """Making sure that the new reconciliation hasn't tried to generate new gen and api keys""" + gen_key_secret = ops_manager.read_gen_key_secret() + api_key_secret = ops_manager.read_api_key_secret() + + assert gen_key_secret.metadata.resource_version == gen_key_resource_version + assert api_key_secret.metadata.resource_version == admin_key_resource_version + + def test_appdb_connection_url_secret(self, ops_manager: MongoDBOpsManager): + assert len(ops_manager.read_appdb_members_from_connection_url_secret()) == 5 + + def test_appdb(self, ops_manager: MongoDBOpsManager, custom_appdb_version: str): + assert ops_manager.appdb_status().get_members() == 5 + assert ops_manager.appdb_status().get_version() == custom_appdb_version + + statefulset = ops_manager.read_appdb_statefulset() + assert statefulset.status.ready_replicas == 5 + assert statefulset.status.current_replicas == 5 + + def test_admin_config_map(self, ops_manager: MongoDBOpsManager): + ops_manager.get_automation_config_tester().reached_version(2) + + @skip_if_local + def test_om_connectivity(self, ops_manager: MongoDBOpsManager): + ops_manager.get_om_tester().assert_healthiness() + + +@pytest.mark.e2e_om_appdb_scale_up_down +class TestOpsManagerAppDbScaleDown: + """ + name: Ops Manager successful appdb scale down + """ + + def test_scale_app_db_down(self, ops_manager: MongoDBOpsManager): + ops_manager.load() + ops_manager["spec"]["applicationDatabase"]["members"] = 3 + ops_manager.update() + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=600) + + def test_appdb(self, ops_manager: MongoDBOpsManager): + assert ops_manager.appdb_status().get_members() == 3 + + statefulset = ops_manager.read_appdb_statefulset() + assert statefulset.status.ready_replicas == 3 + assert statefulset.status.current_replicas == 3 + + def test_admin_config_map(self, ops_manager: MongoDBOpsManager): + ops_manager.get_automation_config_tester().reached_version(3) + + @skip_if_local + def test_om_connectivity(self, ops_manager: MongoDBOpsManager): + ops_manager.get_om_tester().assert_healthiness() diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_appdb_upgrade.py b/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_appdb_upgrade.py new file mode 100644 index 000000000..39493cd67 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_appdb_upgrade.py @@ -0,0 +1,188 @@ +from typing import Optional + +import pytest +from pytest import fixture + +from kubetester import create_or_update +from kubetester.kubetester import ( + skip_if_local, + fixture as yaml_fixture, +) +from kubetester.mongodb import Phase +from kubetester.opsmanager import MongoDBOpsManager + +gen_key_resource_version = None +admin_key_resource_version = None + + +@fixture(scope="module") +def initial_appdb_version(custom_appdb_version: str): + """ + returns the initial appdb version which we update from + """ + if custom_appdb_version.startswith("6."): + # simulate update from 5 to 6 + return "5.0.5-ent" + else: + # simulate upgrade from 4 to 5 or 4 to 4 + return "4.4.20-ent" + + +@fixture(scope="module") +def ops_manager(namespace: str, custom_version: Optional[str], initial_appdb_version: str) -> MongoDBOpsManager: + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_appdb_upgrade.yaml"), namespace=namespace + ) + resource.set_version(custom_version) + resource.set_appdb_version(initial_appdb_version) + return create_or_update(resource) + + +@pytest.mark.e2e_om_appdb_upgrade +class TestOpsManagerCreation: + """ + Creates an Ops Manager instance with AppDB of size 3. The test waits until the AppDB is ready, not the OM resource + """ + + def test_appdb(self, ops_manager: MongoDBOpsManager, initial_appdb_version: str): + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=600) + + assert ops_manager.appdb_status().get_members() == 3 + assert ops_manager.appdb_status().get_version() == initial_appdb_version + db_pods = ops_manager.read_appdb_pods() + for pod in db_pods: + # the appdb pod container 'mongodb' by default has 500M + assert pod.spec.containers[1].resources.requests["memory"] == "500M" + + def test_admin_config_map(self, ops_manager: MongoDBOpsManager): + ops_manager.get_automation_config_tester().reached_version(1) + + @skip_if_local + def test_mongod(self, ops_manager: MongoDBOpsManager, initial_appdb_version: str): + mdb_tester = ops_manager.get_appdb_tester() + mdb_tester.assert_connectivity() + mdb_tester.assert_version(initial_appdb_version) + + def test_appdb_automation_config(self, ops_manager: MongoDBOpsManager): + expected_roles = { + ("admin", "readWriteAnyDatabase"), + ("admin", "dbAdminAnyDatabase"), + ("admin", "clusterMonitor"), + ("admin", "hostManager"), + ("admin", "backup"), + ("admin", "restore"), + } + + # only user should be the Ops Manager user + tester = ops_manager.get_automation_config_tester() + tester.assert_authentication_mechanism_enabled("SCRAM-SHA-256", False) + tester.assert_has_user("mongodb-ops-manager") + tester.assert_user_has_roles("mongodb-ops-manager", expected_roles) + tester.assert_expected_users(1) + tester.assert_authoritative_set(False) + + @skip_if_local + def test_appdb_scram_sha(self, ops_manager: MongoDBOpsManager): + app_db_tester = ops_manager.get_appdb_tester() + app_db_tester.assert_scram_sha_authentication( + "mongodb-ops-manager", + ops_manager.read_appdb_generated_password(), + auth_mechanism="SCRAM-SHA-256", + ) + + def test_appdb_mongodb_options(self, ops_manager: MongoDBOpsManager): + automation_config_tester = ops_manager.get_automation_config_tester() + for process in automation_config_tester.get_replica_set_processes(ops_manager.app_db_name()): + assert process["args2_6"]["operationProfiling"]["mode"] == "slowOp" + + def test_om_reaches_running(self, ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=600) + + def test_appdb_reaches_running(self, ops_manager: MongoDBOpsManager): + ops_manager.appdb_status().assert_abandons_phase(Phase.Running, timeout=100) + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=300) + + def test_appdb_monitoring_is_configured(self, ops_manager: MongoDBOpsManager): + ops_manager.assert_appdb_monitoring_group_was_created() + + def test_om_running(self, ops_manager: MongoDBOpsManager): + ops_manager.get_om_tester().assert_healthiness() + + # TODO check the persistent volumes created + + +@pytest.mark.e2e_om_appdb_upgrade +class TestOpsManagerAppDbUpdateMemory: + """ + Changes memory limits requirements for the AppDB + """ + + def test_appdb_updated(self, ops_manager: MongoDBOpsManager): + ops_manager.load() + ops_manager["spec"]["applicationDatabase"]["podSpec"] = { + "podTemplate": { + "spec": { + "containers": [ + { + "name": "mongodb-agent", + "resources": { + "requests": { + "memory": "350M", + }, + }, + } + ] + } + } + } + ops_manager.update() + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=400) + # Note, that we don't wait for "OM == reconciling" as this phase passes too quickly + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=100) + + def test_appdb(self, ops_manager: MongoDBOpsManager): + db_pods = ops_manager.read_appdb_pods() + for pod in db_pods: + assert pod.spec.containers[1].resources.requests["memory"] == "350M" + + def test_admin_config_map(self, ops_manager: MongoDBOpsManager): + # The version hasn't changed as there were no changes to the automation config + ops_manager.get_automation_config_tester().reached_version(2) + + @skip_if_local + def test_om_is_running(self, ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=400) + ops_manager.get_om_tester().assert_healthiness() + + +@pytest.mark.e2e_om_appdb_upgrade +class TestOpsManagerMixed: + """ + Performs changes to both AppDB and Ops Manager spec + """ + + def test_appdb_and_om_updated(self, ops_manager: MongoDBOpsManager, custom_appdb_version: str): + ops_manager.load() + ops_manager.set_appdb_version(custom_appdb_version) + ops_manager["spec"]["configuration"] = {"mms.helpAndSupportPage.enabled": "true"} + ops_manager.update() + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=900) + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=400) + + ops_manager.backup_status().assert_reaches_phase(Phase.Disabled, timeout=400) + + def test_appdb(self, ops_manager: MongoDBOpsManager, custom_appdb_version: str): + assert ops_manager.appdb_status().get_members() == 3 + assert ops_manager.appdb_status().get_version() == custom_appdb_version + + @skip_if_local + def test_mongod(self, ops_manager: MongoDBOpsManager, custom_appdb_version: str): + mdb_tester = ops_manager.get_appdb_tester() + mdb_tester.assert_connectivity() + mdb_tester.assert_version(custom_appdb_version) + + @skip_if_local + def test_om_connectivity(self, ops_manager: MongoDBOpsManager): + om_tester = ops_manager.get_om_tester() + om_tester.assert_healthiness() + om_tester.assert_support_page_enabled() diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_ops_manager_appdb_monitoring_tls.py b/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_ops_manager_appdb_monitoring_tls.py new file mode 100644 index 000000000..e108f2044 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_ops_manager_appdb_monitoring_tls.py @@ -0,0 +1,150 @@ +import os +from time import sleep +from typing import Optional + +import pymongo +from kubetester import create_secret, read_secret, update_secret +from kubetester.certs import create_mongodb_tls_certs, create_ops_manager_tls_certs +from kubetester.kubetester import KubernetesTester +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.mongodb import Phase +from kubetester.opsmanager import MongoDBOpsManager +from pytest import fixture, mark + +MDB_VERSION = "4.2.1" +CA_FILE_PATH_IN_TEST_POD = "/tests/ca.crt" +OM_NAME = "om-tls-monitored-appdb" + + +@fixture(scope="module") +def ops_manager_certs(namespace: str, issuer: str): + return create_ops_manager_tls_certs(issuer, namespace, OM_NAME) + + +@fixture(scope="module") +def appdb_certs(namespace: str, issuer: str): + create_mongodb_tls_certs(issuer, namespace, f"{OM_NAME}-db", "appdb-om-tls-monitored-appdb-db-cert") + return "appdb" + + +@fixture(scope="module") +@mark.usefixtures("appdb_certs", "ops_manager_certs", "issuer_ca_configmap") +def ops_manager( + namespace: str, + issuer_ca_configmap: str, + appdb_certs: str, + ops_manager_certs: str, + custom_version: Optional[str], + issuer_ca_filepath: str, +) -> MongoDBOpsManager: + + create_secret(namespace, "appdb-secret", {"password": "Hello-World!"}) + + print("Creating OM object") + om = MongoDBOpsManager.from_yaml(yaml_fixture("om_ops_manager_appdb_monitoring_tls.yaml"), namespace=namespace) + om.set_version(custom_version) + + # ensure the requests library will use this CA when communicating with Ops Manager + os.environ["REQUESTS_CA_BUNDLE"] = issuer_ca_filepath + return om.create() + + +@mark.e2e_om_appdb_monitoring_tls +def test_om_created(ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=900) + ops_manager.appdb_status().assert_abandons_phase(Phase.Running, timeout=100) + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=600) + + +@mark.e2e_om_appdb_monitoring_tls +def test_appdb_group_is_monitored(ops_manager: MongoDBOpsManager): + ops_manager.assert_appdb_monitoring_group_was_created() + monitoring_metrics_are_being_sent = build_monitoring_agent_test_func(ops_manager) + KubernetesTester.wait_until(monitoring_metrics_are_being_sent, timeout=120) + + +@mark.e2e_om_appdb_monitoring_tls +def test_appdb_password_can_be_changed(ops_manager: MongoDBOpsManager): + # get measurements for the last minute, they should just work, as monitoring + # is supposed to be working now. + build_monitoring_agent_test_func(ops_manager, period="PT60S")() + + # Change the Secret containing the password + data = {"password": "Hello-World!-new"} + update_secret( + ops_manager.namespace, + ops_manager["spec"]["applicationDatabase"]["passwordSecretKeyRef"]["name"], + data, + ) + + # We know that Ops Manager will detect the changes and be restarted + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=600) + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=800) + + +@mark.e2e_om_appdb_monitoring_tls +def test_new_database_is_monitored_after_restart(ops_manager: MongoDBOpsManager): + # Connect with the new connection string + connection_string = ops_manager.read_appdb_connection_url() + client = pymongo.MongoClient(connection_string, tlsAllowInvalidCertificates=True) + database_name = "new_database" + database = client[database_name] + collection = database["new_collection"] + collection.insert_one({"witness": "database and collection should be created"}) + + # We want to retrieve measurements from "new_database" which will indicate + # that the monitoring agents are working with the new credentials. + KubernetesTester.wait_until( + build_monitoring_agent_test_func(ops_manager, database_name=database_name, period="PT100M"), + timeout=120, + ) + + +# @mark.e2e_om_appdb_monitoring_tls +# def test_enable_tls_on_appdb(ops_manager: MongoDBOpsManager): +# ops_manager.load() +# ops_manager["spec"]["applicationDatabase"]["security"] = { +# "tls": {"ca": "issuer-ca", "secretRef": {"name": "certs-for-appdb"}} +# } +# ops_manager.update() +# +# ops_manager.appdb_status().assert_abandons_phase(Phase.Running, timeout=100) +# ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=600) + + +# @mark.e2e_om_appdb_monitoring_tls +# def test_monitoring_metrics_are_gathered(ops_manager: MongoDBOpsManager): +# one_monitoring_agent_is_showing_metrics = build_monitoring_agent_test_func( +# ops_manager +# ) +# +# # some monitoring metrics should be reported within a few minutes +# KubernetesTester.wait_until(one_monitoring_agent_is_showing_metrics, timeout=120) +# + + +def build_monitoring_agent_test_func( + ops_manager: MongoDBOpsManager, + database_name: str = "admin", + period: str = "P1DT12H", +): + """ + Returns a function that will check for existance of monitoring + measurements in this Ops Manager instance. + """ + appdb_hosts = ops_manager.get_appdb_hosts() + host_ids = [host["id"] for host in appdb_hosts] + project_id = [host["groupId"] for host in appdb_hosts][0] + tester = ops_manager.get_om_tester() + + def one_monitoring_agent_is_showing_metrics(): + for host_id in host_ids: + measurements = tester.api_read_monitoring_measurements( + host_id, + database_name=database_name, + project_id=project_id, + period=period, + ) + return measurements is not None and len(measurements) > 0 + + return one_monitoring_agent_is_showing_metrics diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_ops_manager_backup_light.py b/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_ops_manager_backup_light.py new file mode 100644 index 000000000..5ff6bbb05 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_ops_manager_backup_light.py @@ -0,0 +1,255 @@ +from typing import Optional, Dict + +import time +import jsonpatch + + +import pytest +from kubernetes import client +from kubetester import MongoDB, wait_until + +from kubetester.opsmanager import MongoDBOpsManager +from kubetester.awss3client import AwsS3Client +from kubetester.kubetester import ( + skip_if_local, + fixture as yaml_fixture, +) +from kubetester.mongodb import Phase +from pytest import mark, fixture +from tests.opsmanager.om_ops_manager_backup import ( + HEAD_PATH, + OPLOG_RS_NAME, + new_om_data_store, + create_aws_secret, + S3_SECRET_NAME, + create_s3_bucket, +) + + +DEFAULT_APPDB_USER_NAME = "mongodb-ops-manager" + +""" +This test checks the backup if no separate S3 Metadata database is created and AppDB is used for this. +Note, that it doesn't check for mongodb backup as it's done in 'e2e_om_ops_manager_backup_restore'" +""" + + +@fixture(scope="module") +def s3_bucket(aws_s3_client: AwsS3Client, namespace: str) -> str: + create_aws_secret(aws_s3_client, S3_SECRET_NAME, namespace) + yield from create_s3_bucket(aws_s3_client) + + +@fixture(scope="module") +def ops_manager( + namespace: str, + s3_bucket: str, + custom_version: Optional[str], + custom_appdb_version: str, +) -> MongoDBOpsManager: + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_backup_light.yaml"), namespace=namespace + ) + resource.set_version(custom_version) + resource.set_appdb_version(custom_appdb_version) + resource["spec"]["backup"]["s3Stores"][0]["s3BucketName"] = s3_bucket + + return resource.create() + + +@fixture(scope="module") +def oplog_replica_set(ops_manager, namespace) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=OPLOG_RS_NAME, + ).configure(ops_manager, "development") + + return resource.create() + + +def service_exists(service_name: str, namespace: str) -> bool: + try: + client.CoreV1Api().read_namespaced_service(service_name, namespace) + except client.rest.ApiException: + return False + return True + + +@mark.e2e_om_ops_manager_backup_light +class TestOpsManagerCreation: + def test_create_om(self, ops_manager: MongoDBOpsManager): + """creates a s3 bucket and an OM resource, the S3 configs get created using AppDB. Oplog store is still required.""" + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=900) + ops_manager.backup_status().assert_reaches_phase( + Phase.Pending, + msg_regexp="Oplog Store configuration is required for backup", + timeout=600, + ) + + def test_oplog_mdb_created( + self, + oplog_replica_set: MongoDB, + ): + oplog_replica_set.assert_reaches_phase(Phase.Running) + + def test_add_oplog_config(self, ops_manager: MongoDBOpsManager): + ops_manager.load() + ops_manager["spec"]["backup"]["opLogStores"] = [ + {"name": "oplog1", "mongodbResourceRef": {"name": "my-mongodb-oplog"}} + ] + ops_manager.update() + + ops_manager.backup_status().assert_reaches_phase( + Phase.Running, + timeout=500, + ignore_errors=True, + ) + + @skip_if_local + def test_om( + self, + ops_manager: MongoDBOpsManager, + s3_bucket: str, + aws_s3_client: AwsS3Client, + oplog_replica_set: MongoDB, + ): + om_tester = ops_manager.get_om_tester() + om_tester.assert_healthiness() + for pod_fqdn in ops_manager.backup_daemon_pods_fqdns(): + om_tester.assert_daemon_enabled(pod_fqdn, HEAD_PATH) + om_tester.assert_oplog_stores([new_om_data_store(oplog_replica_set, "oplog1")]) + + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=600) + ops_manager.assert_appdb_monitoring_group_was_created() + + # TODO uncomment when CLOUDP-70468 is fixed and AppDB supports scram-sha-256 + # making sure the s3 config pushed to OM references the appdb + # appdb_replica_set = ops_manager.get_appdb_resource() + # appdb_password = KubernetesTester.read_secret( + # ops_manager.namespace, ops_manager.app_db_password_secret_name() + # )["password"] + # om_tester.assert_s3_stores( + # [ + # new_om_s3_store( + # appdb_replica_set, + # "s3Store1", + # s3_bucket, + # aws_s3_client, + # user_name=DEFAULT_APPDB_USER_NAME, + # password=appdb_password, + # ) + # ] + # ) + + def test_enable_external_connectivity( + self, ops_manager: MongoDBOpsManager, namespace: str + ): + ops_manager.load() + ops_manager["spec"]["externalConnectivity"] = {"type": "LoadBalancer"} + ops_manager.update() + + wait_until( + lambda: service_exists("om-backup-svc-ext", namespace), + timeout=90, + sleep_time=5, + ) + + service = client.CoreV1Api().read_namespaced_service( + "om-backup-svc-ext", namespace + ) + + # Tests that the service is created with both externalConnectivity and backup + # and that it contains the correct ports + assert service.spec.type == "LoadBalancer" + assert len(service.spec.ports) == 2 + assert service.spec.ports[0].port == 8080 + assert service.spec.ports[1].port == 25999 + + def test_disable_external_connectivity( + self, ops_manager: MongoDBOpsManager, namespace: str + ): + + # We dont' have a nice way to delete fields from a resource specification + # in our test env, so we need to achieve it with specific uses of patches + body = {"op": "remove", "path": "/spec/externalConnectivity"} + patch = jsonpatch.JsonPatch([body]) + om = client.CustomObjectsApi().get_namespaced_custom_object( + ops_manager.group, + ops_manager.version, + namespace, + ops_manager.plural, + ops_manager.name, + ) + client.CustomObjectsApi().replace_namespaced_custom_object( + ops_manager.group, + ops_manager.version, + namespace, + ops_manager.plural, + ops_manager.name, + jsonpatch.apply_patch(om, patch), + ) + + wait_until( + lambda: not (service_exists("om-backup-svc-ext", namespace)), + timeout=90, + sleep_time=5, + ) + + +@mark.e2e_om_ops_manager_backup_light +def test_backup_statefulset_remains_after_disabling_backup( + ops_manager: MongoDBOpsManager, +): + ops_manager.load() + ops_manager["spec"]["backup"]["enabled"] = False + ops_manager.update() + + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=200) + + # the backup statefulset should still exist even after we disable backup + ops_manager.read_backup_statefulset() + + +def check_sts_labels(sts, labels: Dict[str, str]): + sts_labels = sts.metadata.labels + + for k in labels: + assert k in sts_labels and sts_labels[k] == labels[k] + + +@mark.e2e_om_ops_manager_backup_light +def test_labels_on_om_and_backup_daemon_and_appdb_sts( + ops_manager: MongoDBOpsManager, namespace: str +): + labels = {"label1": "val1", "label2": "val2"} + + check_sts_labels(ops_manager.read_statefulset(), labels) + check_sts_labels(ops_manager.read_backup_statefulset(), labels) + check_sts_labels(ops_manager.read_appdb_statefulset(), labels) + + +def check_pvc_labels(pvc_name: str, labels: Dict[str, str], namespace: str): + pvc = client.CoreV1Api().read_namespaced_persistent_volume_claim( + pvc_name, namespace + ) + pvc_labels = pvc.metadata.labels + + for k in labels: + assert k in pvc_labels and pvc_labels[k] == labels[k] + + +@mark.e2e_om_ops_manager_backup_light +def test_labels_on_backup_daemon_and_appdb_pvc( + ops_manager: MongoDBOpsManager, namespace: str +): + labels = {"label1": "val1", "label2": "val2"} + + appdb_pvc_name = "data-{}-0".format( + ops_manager.read_appdb_statefulset().metadata.name + ) + check_pvc_labels(appdb_pvc_name, labels, namespace) + backupdaemon_pvc_name = "head-{}-0".format( + ops_manager.read_backup_statefulset().metadata.name + ) + check_pvc_labels(backupdaemon_pvc_name, labels, namespace) diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_ops_manager_backup_liveness_probe.py b/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_ops_manager_backup_liveness_probe.py new file mode 100644 index 000000000..ecb6b5d29 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_ops_manager_backup_liveness_probe.py @@ -0,0 +1,184 @@ +from typing import Optional + +from kubetester import MongoDB +from kubetester.opsmanager import MongoDBOpsManager +from kubetester.awss3client import AwsS3Client +from kubetester.kubetester import ( + skip_if_local, + fixture as yaml_fixture, + KubernetesTester, +) +from kubernetes import client +from kubetester.mongodb import Phase +from pytest import mark, fixture +from tests.opsmanager.om_ops_manager_backup import ( + HEAD_PATH, + OPLOG_RS_NAME, + new_om_data_store, + create_aws_secret, + S3_SECRET_NAME, + create_s3_bucket, +) + +DEFAULT_APPDB_USER_NAME = "mongodb-ops-manager" + +""" +This test checks the backup if no separate S3 Metadata database is created and AppDB is used for this. +Note, that it doesn't check for mongodb backup as it's done in 'e2e_om_ops_manager_backup_restore'" +""" + + +@fixture(scope="module") +def s3_bucket(aws_s3_client: AwsS3Client, namespace: str) -> str: + create_aws_secret(aws_s3_client, S3_SECRET_NAME, namespace) + yield from create_s3_bucket(aws_s3_client) + + +@fixture(scope="module") +def ops_manager( + namespace: str, + s3_bucket: str, + custom_version: Optional[str], + custom_appdb_version: str, +) -> MongoDBOpsManager: + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_backup_light.yaml"), namespace=namespace + ) + resource.set_version(custom_version) + resource.set_appdb_version(custom_appdb_version) + resource["spec"]["backup"]["s3Stores"][0]["s3BucketName"] = s3_bucket + + return resource.create() + + +@fixture(scope="module") +def oplog_replica_set(ops_manager, namespace) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=OPLOG_RS_NAME, + ).configure(ops_manager, "development") + + return resource.create() + + +@mark.e2e_om_ops_manager_backup_liveness_probe +class TestOpsManagerCreation: + def test_create_om(self, ops_manager: MongoDBOpsManager): + """creates a s3 bucket and an OM resource, the S3 configs get created using AppDB. Oplog store is still required.""" + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=900) + ops_manager.backup_status().assert_reaches_phase( + Phase.Pending, + msg_regexp="Oplog Store configuration is required for backup", + timeout=600, + ) + + def test_readiness_probe_is_configured(self, ops_manager: MongoDBOpsManager): + backup_sts = ops_manager.read_backup_statefulset() + readiness_probe = backup_sts.spec.template.spec.containers[0].readiness_probe + assert readiness_probe.period_seconds == 3 + assert readiness_probe.initial_delay_seconds == 1 + assert readiness_probe.success_threshold == 1 + assert readiness_probe.failure_threshold == 3 + + def test_liveness_probe_is_configured(self, ops_manager: MongoDBOpsManager): + backup_sts = ops_manager.read_backup_statefulset() + liveness_probe = backup_sts.spec.template.spec.containers[0].liveness_probe + assert liveness_probe.period_seconds == 30 + assert liveness_probe.initial_delay_seconds == 10 + assert liveness_probe.success_threshold == 1 + assert liveness_probe.failure_threshold == 10 + + def test_oplog_mdb_created( + self, + oplog_replica_set: MongoDB, + ): + oplog_replica_set.assert_reaches_phase(Phase.Running) + + def test_add_oplog_config(self, ops_manager: MongoDBOpsManager): + ops_manager.load() + ops_manager["spec"]["backup"]["opLogStores"] = [ + {"name": "oplog1", "mongodbResourceRef": {"name": "my-mongodb-oplog"}} + ] + ops_manager.update() + + ops_manager.backup_status().assert_reaches_phase( + Phase.Running, + timeout=500, + ignore_errors=True, + ) + + @skip_if_local + def test_om( + self, + ops_manager: MongoDBOpsManager, + oplog_replica_set: MongoDB, + ): + om_tester = ops_manager.get_om_tester() + om_tester.assert_healthiness() + for pod_fqdn in ops_manager.backup_daemon_pods_fqdns(): + om_tester.assert_daemon_enabled(pod_fqdn, HEAD_PATH) + + om_tester.assert_oplog_stores([new_om_data_store(oplog_replica_set, "oplog1")]) + + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=600) + ops_manager.assert_appdb_monitoring_group_was_created() + + +@mark.e2e_om_ops_manager_backup_liveness_probe +def test_backup_daemon_pod_restarts_when_process_is_killed( + ops_manager: MongoDBOpsManager, +): + corev1_client = client.CoreV1Api() + + backup_daemon_pod = corev1_client.read_namespaced_pod( + ops_manager.backup_daemon_pods_names()[0], ops_manager.namespace + ) + + # ensure the pod has not yet been restarted. + assert backup_daemon_pod.status.container_statuses[0].restart_count == 0 + + # get the process id of the Backup Daemon. + cmd = ["/opt/scripts/backup-daemon-liveness-probe.sh"] + process_id = KubernetesTester.run_command_in_pod_container( + ops_manager.backup_daemon_pods_names()[0], + ops_manager.namespace, + cmd, + ) + + kill_cmd = ["/bin/sh", "-c", f"kill -9 {process_id}"] + + # kill the process, resulting in the liveness probe terminating the backup daemon. + result = KubernetesTester.run_command_in_pod_container( + ops_manager.backup_daemon_pods_names()[0], + ops_manager.namespace, + kill_cmd, + ) + + # ensure the process was existed and was terminated successfully. + assert "No such process" not in result + + def backup_daemon_container_has_restarted(): + try: + pod = corev1_client.read_namespaced_pod(ops_manager.backup_daemon_pods_names()[0], ops_manager.namespace) + return pod.status.container_statuses[0].restart_count > 0 + except Exception as e: + print("Error reading pod state: " + str(e)) + return False + + KubernetesTester.wait_until(backup_daemon_container_has_restarted, timeout=3500) + + +@mark.e2e_om_ops_manager_backup_liveness_probe +def test_backup_daemon_reaches_ready_state(ops_manager: MongoDBOpsManager): + corev1_client = client.CoreV1Api() + + def backup_daemon_is_ready(): + try: + pod = corev1_client.read_namespaced_pod(ops_manager.backup_daemon_pods_names()[0], ops_manager.namespace) + return pod.status.container_statuses[0].ready + except Exception as e: + print("Error checking if pod is ready: " + str(e)) + return False + + KubernetesTester.wait_until(backup_daemon_is_ready, timeout=300) diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_ops_manager_pod_spec.py b/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_ops_manager_pod_spec.py new file mode 100644 index 000000000..6e50c25df --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_ops_manager_pod_spec.py @@ -0,0 +1,383 @@ +""" +The fist stage of an Operator-upgrade test. +It creates an OM instance with maximum features (backup, scram etc). +Also it creates a MongoDB referencing the OM. +""" + +from typing import Optional + +from kubernetes import client +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.mongodb import Phase +from kubetester.opsmanager import MongoDBOpsManager +from kubetester.custom_podspec import assert_volume_mounts_are_equal +from pytest import fixture, mark + + +@fixture(scope="module") +def ops_manager( + namespace: str, custom_version: Optional[str], custom_appdb_version: str +) -> MongoDBOpsManager: + """The fixture for Ops Manager to be created.""" + om: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_pod_spec.yaml"), namespace=namespace + ) + om.set_version(custom_version) + om.set_appdb_version(custom_appdb_version) + return om.create() + + +@mark.e2e_om_ops_manager_pod_spec +class TestOpsManagerCreation: + def test_appdb_0_sts_agents_havent_reached_running_state( + self, ops_manager: MongoDBOpsManager + ): + ops_manager.appdb_status().assert_reaches_phase( + Phase.Pending, + msg_regexp="Application Database Agents haven't reached Running state yet", + timeout=100, + ) + + def test_appdb_1_reaches_running_phase_1(self, ops_manager: MongoDBOpsManager): + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=500) + ops_manager.appdb_status().assert_empty_status_resources_not_ready() + + def test_om_status_0_sts_not_ready(self, ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_reaches_phase( + Phase.Pending, msg_regexp="StatefulSet not ready", timeout=100 + ) + + def test_om_status_1_pods_not_ready(self, ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_status_resource_not_ready( + ops_manager.name, msg_regexp="Not all the Pods are ready \(total: 1.*\)" + ) + + def test_om_status_2_reaches_running_phase(self, ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=800) + ops_manager.om_status().assert_empty_status_resources_not_ready() + + def test_appdb_3_reaches_running_phase_2(self, ops_manager: MongoDBOpsManager): + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=600) + + def test_backup_0_reaches_pending_phase(self, ops_manager: MongoDBOpsManager): + ops_manager.backup_status().assert_reaches_phase( + Phase.Pending, msg_regexp=".*is required for backup.*", timeout=900 + ) + ops_manager.backup_status().assert_empty_status_resources_not_ready() + + def test_backup_1_pod_becomes_ready(self, ops_manager: MongoDBOpsManager): + """backup web server is up and running""" + ops_manager.wait_until_backup_pods_become_ready() + + def test_appdb_pod_template_containers(self, ops_manager: MongoDBOpsManager): + appdb_sts = ops_manager.read_appdb_statefulset() + assert len(appdb_sts.spec.template.spec.containers) == 4 + + assert ( + appdb_sts.spec.template.spec.service_account_name + == "mongodb-enterprise-appdb" + ) + + appdb_agent_container = appdb_sts.spec.template.spec.containers[2] + assert appdb_agent_container.name == "mongodb-agent" + assert appdb_agent_container.resources.limits["cpu"] == "250m" + assert appdb_agent_container.resources.limits["memory"] == "350M" + + assert appdb_sts.spec.template.spec.containers[0].name == "appdb-sidecar" + assert appdb_sts.spec.template.spec.containers[0].image == "busybox" + assert appdb_sts.spec.template.spec.containers[0].command == ["sleep"] + assert appdb_sts.spec.template.spec.containers[0].args == ["infinity"] + + def test_appdb_persistence(self, ops_manager: MongoDBOpsManager, namespace: str): + # appdb pod volume claim template + appdb_sts = ops_manager.read_appdb_statefulset() + assert len(appdb_sts.spec.volume_claim_templates) == 1 + assert appdb_sts.spec.volume_claim_templates[0].metadata.name == "data" + assert ( + appdb_sts.spec.volume_claim_templates[0].spec.resources.requests["storage"] + == "1G" + ) + + for pod in ops_manager.read_appdb_pods(): + # pod volume claim + expected_claim_name = f"data-{pod.metadata.name}" + claims = [ + volume + for volume in pod.spec.volumes + if getattr(volume, "persistent_volume_claim") + ] + assert len(claims) == 1 + assert claims[0].name == "data" + assert claims[0].persistent_volume_claim.claim_name == expected_claim_name + + # volume claim created + pvc = client.CoreV1Api().read_namespaced_persistent_volume_claim( + expected_claim_name, namespace + ) + assert pvc.status.phase == "Bound" + assert pvc.spec.resources.requests["storage"] == "1G" + + def test_om_pod_spec(self, ops_manager: MongoDBOpsManager): + sts = ops_manager.read_statefulset() + assert ( + sts.spec.template.spec.service_account_name + == "mongodb-enterprise-ops-manager" + ) + + assert len(sts.spec.template.spec.containers) == 1 + om_container = sts.spec.template.spec.containers[0] + assert om_container.resources.limits["cpu"] == "700m" + assert om_container.resources.limits["memory"] == "6G" + + assert sts.spec.template.metadata.annotations["key1"] == "value1" + assert len(sts.spec.template.spec.tolerations) == 1 + assert sts.spec.template.spec.tolerations[0].key == "key" + assert sts.spec.template.spec.tolerations[0].operator == "Exists" + assert sts.spec.template.spec.tolerations[0].effect == "NoSchedule" + + def test_om_container_override(self, ops_manager: MongoDBOpsManager): + sts = ops_manager.read_statefulset() + om_container = sts.spec.template.spec.containers[0].to_dict() + # Readiness probe got 'failure_threshold' overridden, everything else is the same + # New volume mount was added + expected_spec = { + "name": "mongodb-ops-manager", + "readiness_probe": { + "http_get": { + "host": None, + "http_headers": None, + "path": "/monitor/health", + "port": 8080, + "scheme": "HTTP", + }, + "failure_threshold": 20, + "timeout_seconds": 5, + "period_seconds": 5, + "success_threshold": 1, + "initial_delay_seconds": 5, + "_exec": None, + "tcp_socket": None, + }, + "startup_probe": { + "http_get": { + "host": None, + "http_headers": None, + "path": "/monitor/health", + "port": 8080, + "scheme": "HTTP", + }, + "failure_threshold": 30, + "timeout_seconds": 10, + "period_seconds": 25, + "success_threshold": 1, + "initial_delay_seconds": 1, + "_exec": None, + "tcp_socket": None, + }, + "volume_mounts": [ + { + "name": "gen-key", + "mount_path": "/mongodb-ops-manager/.mongodb-mms", + "sub_path": None, + "sub_path_expr": None, + "mount_propagation": None, + "read_only": True, + }, + { + "name": "mongodb-uri", + "mount_path": "/mongodb-ops-manager/.mongodb-mms-connection-string", + "sub_path": None, + "sub_path_expr": None, + "mount_propagation": None, + "read_only": True, + }, + { + "name": "ops-manager-scripts", + "mount_path": "/opt/scripts", + "sub_path": None, + "sub_path_expr": None, + "mount_propagation": None, + "read_only": True, + }, + { + "name": "test-volume", + "mount_path": "/somewhere", + "sub_path": None, + "sub_path_expr": None, + "mount_propagation": None, + "read_only": None, + }, + { + "name": "data", + "mount_path": "/mongodb-ops-manager/logs", + "sub_path": "logs", + "sub_path_expr": None, + "mount_propagation": None, + "read_only": None, + }, + { + "name": "data", + "mount_path": "/mongodb-ops-manager/tmp", + "sub_path": "tmp-ops-manager", + "sub_path_expr": None, + "mount_propagation": None, + "read_only": None, + }, + { + "name": "data", + "mount_path": "/tmp", + "sub_path": "tmp", + "sub_path_expr": None, + "mount_propagation": None, + "read_only": None, + }, + { + "name": "data", + "mount_path": "/mongodb-ops-manager/conf", + "sub_path": "conf", + "sub_path_expr": None, + "mount_propagation": None, + "read_only": None, + }, + { + "name": "data", + "mount_path": "/etc/mongodb-mms", + "sub_path": "etc-ops-manager", + "sub_path_expr": None, + "mount_propagation": None, + "read_only": None, + }, + { + "name": "data", + "mount_path": "/mongodb-ops-manager/mongodb-releases", + "sub_path": "mongodb-releases", + "sub_path_expr": None, + "mount_propagation": None, + "read_only": None, + }, + ], + } + + for k in expected_spec: + if k == "volume_mounts": + continue + assert om_container[k] == expected_spec[k] + + assert_volume_mounts_are_equal( + om_container["volume_mounts"], expected_spec["volume_mounts"] + ) + + # new volume was added and the old ones ('gen-key' and 'ops-manager-scripts') stayed there + assert len(sts.spec.template.spec.volumes) == 5 + + def test_backup_pod_spec(self, ops_manager: MongoDBOpsManager): + backup_sts = ops_manager.read_backup_statefulset() + assert ( + backup_sts.spec.template.spec.service_account_name + == "mongodb-enterprise-ops-manager" + ) + + assert len(backup_sts.spec.template.spec.containers) == 1 + om_container = backup_sts.spec.template.spec.containers[0] + assert om_container.resources.requests["cpu"] == "500m" + assert om_container.resources.requests["memory"] == "4500M" + + assert len(backup_sts.spec.template.spec.host_aliases) == 1 + assert backup_sts.spec.template.spec.host_aliases[0].ip == "1.2.3.4" + + +@mark.e2e_om_ops_manager_pod_spec +class TestOpsManagerUpdate: + def test_om_updated(self, ops_manager: MongoDBOpsManager): + ops_manager.load() + # adding annotations + ops_manager["spec"]["applicationDatabase"]["podSpec"]["podTemplate"][ + "metadata" + ] = {"annotations": {"annotation1": "val"}} + + # changing memory and adding labels for OM + ops_manager["spec"]["statefulSet"]["spec"]["template"]["spec"]["containers"][0][ + "resources" + ]["limits"]["memory"] = "5G" + ops_manager["spec"]["statefulSet"]["spec"]["template"]["metadata"]["labels"] = { + "additional": "foo" + } + + # termination_grace_period_seconds for Backup + ops_manager["spec"]["backup"]["statefulSet"]["spec"]["template"]["spec"][ + "terminationGracePeriodSeconds" + ] = 10 + + ops_manager.update() + + def test_appdb_0_sts_not_ready(self, ops_manager: MongoDBOpsManager): + ops_manager.appdb_status().assert_reaches_phase( + Phase.Pending, msg_regexp="StatefulSet not ready", timeout=100 + ) + + def test_appdb_1_pods_not_ready(self, ops_manager: MongoDBOpsManager): + ops_manager.appdb_status().assert_status_resource_not_ready( + ops_manager.app_db_name(), + msg_regexp="Not all the Pods are ready \(total: 3.*\)", + ) + + def test_appdb_2_reaches_running_phase_1(self, ops_manager: MongoDBOpsManager): + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=500) + ops_manager.appdb_status().assert_empty_status_resources_not_ready() + + def test_om_status_0_sts_not_ready(self, ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_reaches_phase( + Phase.Pending, msg_regexp="StatefulSet not ready", timeout=100 + ) + + def test_om_status_1_pods_not_ready(self, ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_status_resource_not_ready( + ops_manager.name, msg_regexp="Not all the Pods are ready \(total: 1.*\)" + ) + + def test_om_status_2_reaches_running_phase(self, ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=500) + ops_manager.om_status().assert_empty_status_resources_not_ready() + + def test_backup_0_reaches_pending_phase(self, ops_manager: MongoDBOpsManager): + ops_manager.backup_status().assert_reaches_phase( + Phase.Pending, msg_regexp=".*is required for backup.*", timeout=900 + ) + ops_manager.backup_status().assert_empty_status_resources_not_ready() + + def test_backup_1_pod_becomes_ready(self, ops_manager: MongoDBOpsManager): + """backup web server is up and running""" + ops_manager.wait_until_backup_pods_become_ready() + + def test_appdb_pod_template(self, ops_manager: MongoDBOpsManager): + appdb_sts = ops_manager.read_appdb_statefulset() + assert len(appdb_sts.spec.template.spec.containers) == 4 + + appdb_mongod_container = appdb_sts.spec.template.spec.containers[1] + assert appdb_mongod_container.name == "mongod" + + appdb_agent_container = appdb_sts.spec.template.spec.containers[2] + assert appdb_agent_container.name == "mongodb-agent" + + appdb_agent_monitoring_container = appdb_sts.spec.template.spec.containers[3] + assert appdb_agent_monitoring_container.name == "mongodb-agent-monitoring" + + assert appdb_sts.spec.template.metadata.annotations == {"annotation1": "val"} + + def test_om_pod_spec(self, ops_manager: MongoDBOpsManager): + sts = ops_manager.read_statefulset() + assert len(sts.spec.template.spec.containers) == 1 + om_container = sts.spec.template.spec.containers[0] + assert om_container.resources.limits["cpu"] == "700m" + assert om_container.resources.limits["memory"] == "5G" + + assert sts.spec.template.metadata.annotations["key1"] == "value1" + assert len(sts.spec.template.metadata.labels) == 4 + assert sts.spec.template.metadata.labels["additional"] == "foo" + assert len(sts.spec.template.spec.tolerations) == 1 + + def test_backup_pod_spec(self, ops_manager: MongoDBOpsManager): + backup_sts = ops_manager.read_backup_statefulset() + + assert len(backup_sts.spec.template.spec.host_aliases) == 1 + assert backup_sts.spec.template.spec.termination_grace_period_seconds == 10 diff --git a/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_ops_manager_secure_config.py b/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_ops_manager_secure_config.py new file mode 100644 index 000000000..504131e1e --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/opsmanager/withMonitoredAppDB/om_ops_manager_secure_config.py @@ -0,0 +1,202 @@ +import time +from typing import Optional + +import pytest +from pytest import fixture +from tests.opsmanager.om_ops_manager_backup import BLOCKSTORE_RS_NAME, OPLOG_RS_NAME + +from kubernetes import client +from kubetester.kubetester import KubernetesTester +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.mongodb import MongoDB, Phase +from kubetester.opsmanager import MongoDBOpsManager + +MONGO_URI_VOLUME_MOUNT_NAME = "mongodb-uri" +MONGO_URI_VOLUME_MOUNT_PATH = "/mongodb-ops-manager/.mongodb-mms-connection-string" + + +@fixture(scope="module") +def ops_manager( + namespace: str, custom_version: Optional[str], custom_appdb_version: str +) -> MongoDBOpsManager: + KubernetesTester.create_secret(namespace, "my-password", {"password": "password"}) + + resource = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_secure_config.yaml"), namespace=namespace + ) + + resource["spec"]["applicationDatabase"]["passwordSecretKeyRef"] = { + "name": "my-password", + } + resource.set_version(custom_version) + resource.set_appdb_version(custom_appdb_version) + + return resource.create() + + +@fixture(scope="module") +def oplog_replica_set(ops_manager, namespace) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=OPLOG_RS_NAME, + ).configure(ops_manager, "oplog") + return resource.create() + + +@fixture(scope="module") +def blockstore_replica_set(ops_manager) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=ops_manager.namespace, + name=BLOCKSTORE_RS_NAME, + ).configure(ops_manager, "blockstore") + + return resource.create() + + +@pytest.mark.e2e_om_ops_manager_secure_config +def test_om_creation(ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=900) + + +@pytest.mark.e2e_om_ops_manager_secure_config +def test_appdb_monitoring_configured(ops_manager: MongoDBOpsManager): + ops_manager.appdb_status().assert_abandons_phase(Phase.Running) + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=900) + ops_manager.assert_appdb_monitoring_group_was_created() + + +@pytest.mark.e2e_om_ops_manager_secure_config +def test_backing_dbs_created( + oplog_replica_set: MongoDB, blockstore_replica_set: MongoDB +): + oplog_replica_set.assert_reaches_phase(Phase.Running) + blockstore_replica_set.assert_reaches_phase(Phase.Running) + + +@pytest.mark.e2e_om_ops_manager_secure_config +def test_backup_enabled(ops_manager: MongoDBOpsManager): + ops_manager.load() + ops_manager["spec"]["backup"]["enabled"] = True + ops_manager.update() + ops_manager.backup_status().assert_reaches_phase(Phase.Running, timeout=600) + + +@pytest.mark.e2e_om_ops_manager_secure_config +def test_connection_string_secret_was_created(ops_manager: MongoDBOpsManager): + secret_data = KubernetesTester.read_secret( + ops_manager.namespace, ops_manager.get_appdb_connection_url_secret_name() + ) + assert "connectionString" in secret_data + + +@pytest.mark.e2e_om_ops_manager_secure_config +def test_ops_manager_pod_template_was_annotated(ops_manager: MongoDBOpsManager): + sts = ops_manager.read_statefulset() + pod_template = sts.spec.template + assert "connectionStringHash" in pod_template.metadata.annotations + assert pod_template.metadata.annotations["connectionStringHash"] != "" + + +@pytest.mark.e2e_om_ops_manager_secure_config +def test_backup_pod_template_was_annotated(ops_manager: MongoDBOpsManager): + sts = ops_manager.read_backup_statefulset() + pod_template = sts.spec.template + assert "connectionStringHash" in pod_template.metadata.annotations + assert pod_template.metadata.annotations["connectionStringHash"] != "" + + +@pytest.mark.e2e_om_ops_manager_secure_config +def test_connection_string_is_configured_securely(ops_manager: MongoDBOpsManager): + sts = ops_manager.read_statefulset() + om_container = sts.spec.template.spec.containers[0] + volume_mounts: list[client.V1VolumeMount] = om_container.volume_mounts + + connection_string_volume_mount = [ + vm for vm in volume_mounts if vm.name == MONGO_URI_VOLUME_MOUNT_NAME + ][0] + assert connection_string_volume_mount.mount_path == MONGO_URI_VOLUME_MOUNT_PATH + + +@pytest.mark.e2e_om_ops_manager_secure_config +def test_changing_app_db_password_triggers_rolling_restart( + ops_manager: MongoDBOpsManager, +): + KubernetesTester.update_secret( + ops_manager.namespace, + "my-password", + { + "password": "new-password", + }, + ) + # unfortunately changing the external secret doesn't change the metadata.generation so we cannot track + # when the Operator started working on the spec - let's just wait for a bit + time.sleep(5) + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=400) + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=900) + ops_manager.backup_status().assert_reaches_phase(Phase.Running, timeout=200) + + +@pytest.mark.e2e_om_ops_manager_secure_config +def test_no_unnecessary_rolling_upgrades_happen( + skip_if_om5: None, + ops_manager: MongoDBOpsManager, + custom_appdb_version: str, +): + sts = ops_manager.read_statefulset() + old_generation = sts.metadata.generation + old_hash = sts.spec.template.metadata.annotations["connectionStringHash"] + + backup_sts = ops_manager.read_backup_statefulset() + old_backup_generation = backup_sts.metadata.generation + old_backup_hash = backup_sts.spec.template.metadata.annotations[ + "connectionStringHash" + ] + + assert old_backup_hash == old_hash + + ops_manager.load() + ops_manager["spec"]["applicationDatabase"]["version"] = custom_appdb_version + ops_manager.update() + + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=500) + + sts = ops_manager.read_statefulset() + assert ( + sts.metadata.generation == old_generation + ), "no change should have happened to the Ops Manager stateful set" + assert ( + sts.spec.template.metadata.annotations["connectionStringHash"] == old_hash + ), "connection string hash should have remained the same" + + backup_sts = ops_manager.read_backup_statefulset() + assert ( + backup_sts.metadata.generation == old_backup_generation + ), "no change should have happened to the backup stateful set" + assert ( + backup_sts.spec.template.metadata.annotations["connectionStringHash"] + == old_backup_hash + ), "connection string hash should have remained the same" + + +@pytest.mark.e2e_om_ops_manager_secure_config +def test_connection_string_secret_was_updated( + skip_if_om5: None, ops_manager: MongoDBOpsManager +): + connection_string = ops_manager.read_appdb_connection_url() + + assert "new-password" in connection_string + + +@pytest.mark.e2e_om_ops_manager_secure_config +def test_appdb_project_has_correct_auth_tls(ops_manager: MongoDBOpsManager): + automation_config_tester = ops_manager.get_automation_config_tester() + auth = automation_config_tester.automation_config["auth"] + + automation_config_tester.assert_agent_user("mms-automation-agent") + assert auth["keyfile"] == "/var/lib/mongodb-mms-automation/authentication/keyfile" + + # These should have been set to random values + assert auth["autoPwd"] != "" + assert auth["key"] != "" diff --git a/docker/mongodb-enterprise-tests/tests/probes/conftest.py b/docker/mongodb-enterprise-tests/tests/probes/conftest.py new file mode 100644 index 000000000..ab10d8672 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/probes/conftest.py @@ -0,0 +1,15 @@ +from tests.conftest import default_operator + + +def pytest_runtest_setup(item): + """Adds the default_operator fixture in case it is not there already. + + If the test has the "no_operator" fixture, the Operator will not be installed + but instead it will rely on the currently installed operator. This is handy to + run local tests.""" + default_operator_name = default_operator.__name__ + if ( + default_operator_name not in item.fixturenames + and "no_operator" not in item.fixturenames + ): + item.fixturenames.insert(0, default_operator_name) diff --git a/docker/mongodb-enterprise-tests/tests/probes/fixtures/deployment_tls.json b/docker/mongodb-enterprise-tests/tests/probes/fixtures/deployment_tls.json new file mode 100644 index 000000000..e051865d2 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/probes/fixtures/deployment_tls.json @@ -0,0 +1 @@ +{ "auth": { "authoritativeSet": false, "autoAuthMechanism": "MONGODB-CR", "autoAuthMechanisms": [], "autoAuthRestrictions": [], "disabled": true, "usersDeleted": [], "usersWanted": [] }, "backupVersions": [ { "hostname": "test-tls-additional-domains-0.test-tls-additional-domains-svc.dev.svc.cluster.local", "name": "6.6.0.959-1" }, { "hostname": "test-tls-additional-domains-1.test-tls-additional-domains-svc.dev.svc.cluster.local", "name": "6.6.0.959-1" }, { "hostname": "test-tls-additional-domains-2.test-tls-additional-domains-svc.dev.svc.cluster.local", "name": "6.6.0.959-1" } ], "balancer": {}, "cpsModules": [], "indexConfigs": [], "kerberos": { "serviceName": "mongodb" }, "ldap": {}, "mongosqlds": [], "mongots": [], "monitoringVersions": [ { "hostname": "test-tls-additional-domains-0.test-tls-additional-domains-svc.dev.svc.cluster.local", "name": "6.4.0.433-1" } ], "onlineArchiveModules": [], "options": { "downloadBase": "/var/lib/mongodb-mms-automation", "downloadBaseWindows": "%SystemDrive%\\MMSAutomation\\versions" }, "processes": [ { "args2_6": { "net": { "port": 27017, "tls": { "PEMKeyFile": "/mongodb-automation/server.pem", "mode": "requireSSL" } }, "replication": { "replSetName": "test-tls-additional-domains" }, "storage": { "dbPath": "/data" }, "systemLog": { "destination": "file", "path": "/var/log/mongodb-mms-automation/mongodb.log" } }, "authSchemaVersion": 5, "featureCompatibilityVersion": "3.6", "hostname": "test-tls-additional-domains-0.test-tls-additional-domains-svc.dev.svc.cluster.local", "name": "test-tls-additional-domains-0", "processType": "mongod", "version": "3.6.8" }, { "args2_6": { "net": { "port": 27017, "tls": { "PEMKeyFile": "/mongodb-automation/server.pem", "mode": "requireSSL" } }, "replication": { "replSetName": "test-tls-additional-domains" }, "storage": { "dbPath": "/data" }, "systemLog": { "destination": "file", "path": "/var/log/mongodb-mms-automation/mongodb.log" } }, "authSchemaVersion": 5, "featureCompatibilityVersion": "3.6", "hostname": "test-tls-additional-domains-1.test-tls-additional-domains-svc.dev.svc.cluster.local", "name": "test-tls-additional-domains-1", "processType": "mongod", "version": "3.6.8" }, { "args2_6": { "net": { "port": 27017, "tls": { "PEMKeyFile": "/mongodb-automation/server.pem", "mode": "requireSSL" } }, "replication": { "replSetName": "test-tls-additional-domains" }, "storage": { "dbPath": "/data" }, "systemLog": { "destination": "file", "path": "/var/log/mongodb-mms-automation/mongodb.log" } }, "authSchemaVersion": 5, "featureCompatibilityVersion": "3.6", "hostname": "test-tls-additional-domains-2.test-tls-additional-domains-svc.dev.svc.cluster.local", "name": "test-tls-additional-domains-2", "processType": "mongod", "version": "3.6.8" } ], "replicaSets": [ { "_id": "test-tls-additional-domains", "members": [ { "_id": 0, "host": "test-tls-additional-domains-0", "priority": 1, "votes": 1 }, { "_id": 1, "host": "test-tls-additional-domains-1", "priority": 1, "votes": 1 }, { "_id": 2, "host": "test-tls-additional-domains-2", "priority": 1, "votes": 1 } ], "protocolVersion": "1" } ], "roles": [], "sharding": [], "tls": { "CAFilePath": "/mongodb-automation/ca.pem", "clientCertificateMode": "OPTIONAL" }, "version": 138 } diff --git a/docker/mongodb-enterprise-tests/tests/probes/replication_state_awareness.py b/docker/mongodb-enterprise-tests/tests/probes/replication_state_awareness.py new file mode 100644 index 000000000..af0375381 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/probes/replication_state_awareness.py @@ -0,0 +1,141 @@ +""" +replication_state_awareness tests that the agent on each Pod is aware of +replication_state and that the entry is writen to '{statuses[0].ReplicaStatus}' +in the file /var/log/mongodb-mms-automation/agent-health-status.json +""" + +import asyncio +import functools +import logging +import random +import string +import time +from typing import Callable, Dict, List + +import pymongo +import yaml +from kubernetes.client.rest import ApiException +from kubetester import find_fixture, wait_until +from kubetester.mongodb import MongoDB, Phase, generic_replicaset +from kubetester.mongotester import upload_random_data +from pytest import fixture, mark + + +def large_json_generator() -> Callable[[], Dict]: + """ + Returns a function that generates a dictionary with a random attribute. + """ + _doc = yaml.safe_load(open(find_fixture("deployment_tls.json"))) + rand_generator = random.SystemRandom() + + def inner() -> Dict: + doc = _doc.copy() + random_id = "".join( + rand_generator.choice(string.ascii_uppercase + string.digits) + for _ in range(30) + ) + doc["json_generator_id"] = random_id + + return doc + + return inner + + +async def upload_random_data_async( + client: pymongo.MongoClient, task_name: str = None, count: int = 50_000 +): + fn = functools.partial( + upload_random_data, + client=client, + generation_function=large_json_generator(), + count=count, + task_name=task_name, + ) + return await asyncio.get_event_loop().run_in_executor(None, fn) + + +def create_writing_task( + client: pymongo.MongoClient, name: str, count: int +) -> asyncio.Task: + """ + Creates an async Task that uploads documents to a MongoDB database. + Async tasks in Python start right away after they are created. + """ + return asyncio.create_task(upload_random_data_async(client, name, count)) + + +def create_writing_tasks( + client: pymongo.MongoClient, prefix: str, task_sizes: List[int] = None +) -> asyncio: + """ + Creates many async tasks to upload documents to a MongoDB database. + """ + return [ + create_writing_task(client, prefix + str(task), task) for task in task_sizes + ] + + +@fixture(scope="module") +def replica_set(namespace: str) -> MongoDB: + rs = generic_replicaset(namespace, version="4.4.2") + + rs["spec"]["persistent"] = True + return rs.create() + + +@mark.e2e_replication_state_awareness +def test_replicaset_reaches_running_state(replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running, timeout=600) + + +@mark.e2e_replication_state_awareness +def test_inserts_50k_documents(replica_set: MongoDB): + client = replica_set.tester().client + + upload_random_data( + client, + 50_000, + generation_function=large_json_generator(), + task_name="uploader_50k", + ) + + +@mark.e2e_replication_state_awareness +@mark.asyncio +async def test_fill_up_database(replica_set: MongoDB): + """ + Writes 1 million documents to the database. + """ + client: pymongo.MongoClient = replica_set.tester().client + + tasks = create_writing_tasks(client, "uploader_", [500_000, 400_000, 100_000]) + for task in tasks: + await task + + logging.info("All uploaders have finished.") + + +@mark.e2e_replication_state_awareness +def test_kill_pod_while_writing(replica_set: MongoDB): + """Keeps writing documents to the database while it is being + restarted.""" + logging.info( + "Restarting StatefulSet holding the MongoDBs: sts/{}".format(replica_set.name) + ) + replica_set["spec"]["podSpec"] = { + "podTemplate": { + "spec": { + "containers": [ + { + "name": "mongodb-enterprise-database", + "resources": {"limits": {"cpu": "2", "memory": "2Gi"}}, + } + ] + } + } + } + current_gen = replica_set.get_generation() + replica_set.update() + wait_until(lambda: replica_set.get_generation() >= current_gen) + + replica_set.assert_reaches_phase(Phase.Running, timeout=800) diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/__init__.py b/docker/mongodb-enterprise-tests/tests/replicaset/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/conftest.py b/docker/mongodb-enterprise-tests/tests/replicaset/conftest.py new file mode 100644 index 000000000..cb0664057 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/conftest.py @@ -0,0 +1,4 @@ +def pytest_runtest_setup(item): + """This allows to automatically install the default Operator before running any test""" + if "default_operator" not in item.fixturenames: + item.fixturenames.insert(0, "default_operator") diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-8-members.yaml b/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-8-members.yaml new file mode 100644 index 000000000..dea8f4920 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-8-members.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: big-replica-set +spec: + members: 8 + version: 4.0.17 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + persistent: false + diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-custom-podspec.yaml b/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-custom-podspec.yaml new file mode 100644 index 000000000..b92e121f4 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-custom-podspec.yaml @@ -0,0 +1,50 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: my-replica-set-custom-podspec +spec: + members: 1 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + persistent: true + podSpec: + podTemplate: + metadata: + annotations: + key1: "val1" + spec: + volumes: + - name: test-volume + emptyDir: {} + containers: + - name: side-car + image: busybox:latest + command: ["/bin/sh"] + args: ["-c", "echo ok > /somewhere/busybox_file && sleep infinity"] + volumeMounts: + - mountPath: /somewhere + name: test-volume + - name: mongodb-enterprise-database + resources: + limits: + cpu: "2" + requests: + cpu: "1" + volumeMounts: + - mountPath: /somewhere + name: test-volume + hostAliases: + - ip: "1.2.3.4" + hostnames: ["hostname"] + terminationGracePeriodSeconds: 30 + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + topologyKey: "mykey-rs" + weight: 50 diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-double.yaml b/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-double.yaml new file mode 100644 index 000000000..205d6516f --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-double.yaml @@ -0,0 +1,12 @@ +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: my-replica-set-double +spec: + members: 2 + version: 4.4.0-ent + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-downgrade.yaml b/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-downgrade.yaml new file mode 100644 index 000000000..8726915c8 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-downgrade.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: my-replica-set-downgrade +spec: + members: 3 + version: 4.4.2 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + persistent: false diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-ent.yaml b/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-ent.yaml new file mode 100644 index 000000000..ac7cadd6b --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-ent.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: rs001-ent +spec: + members: 3 + version: 4.0.15-ent + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + persistent: false diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-externally-exposed.yaml b/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-externally-exposed.yaml new file mode 100644 index 000000000..3ae9ad717 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-externally-exposed.yaml @@ -0,0 +1,14 @@ +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: my-replica-set-externally-exposed +spec: + members: 1 + version: 4.4.0 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + persistent: false + exposedExternally: true diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-invalid.yaml b/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-invalid.yaml new file mode 100644 index 000000000..04a1126dd --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-invalid.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: my-invalid-replica-set +spec: + members: 3 + version: 10.0.0 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + persistent: false diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-liveness.yaml b/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-liveness.yaml new file mode 100644 index 000000000..8fb76b625 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-liveness.yaml @@ -0,0 +1,17 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: my-replica-set +spec: + members: 3 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + persistent: true + # Use a version that does not exist + # This way mongod will never start + version: 4.4.100 diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-mongod-options.yaml b/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-mongod-options.yaml new file mode 100644 index 000000000..116c440b7 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-mongod-options.yaml @@ -0,0 +1,21 @@ +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: my-replica-set-options +spec: + members: 3 + version: 4.4.0-ent + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + persistent: false + additionalMongodConfig: + systemLog: + logAppend: true + verbosity: 4 + operationProfiling: + mode: slowOp + net: + port: 30000 diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-pv-multiple.yaml b/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-pv-multiple.yaml new file mode 100644 index 000000000..9782e1d2a --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-pv-multiple.yaml @@ -0,0 +1,27 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: rs001-pv-multiple + labels: + label1: val1 + label2: val2 +spec: + members: 2 + version: 4.4.0 + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + type: ReplicaSet + persistent: true + podSpec: + persistence: + multiple: + data: + storage: 2Gi + storageClass: gp2 + journal: + storage: 1Gi + logs: + storage: 1G # 1G is the minimum PV possible in EBS diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-pv.yaml b/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-pv.yaml new file mode 100644 index 000000000..b756dca84 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-pv.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: rs001-pv +spec: + members: 3 + version: 4.4.0 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + persistent: true + podSpec: + storage: 2G + storageClass: gp2 diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-single.yaml b/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-single.yaml new file mode 100644 index 000000000..d47bf2e85 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-single.yaml @@ -0,0 +1,16 @@ +# This is the simplest replica set possible (1 member only) - use it for the tests not focused on replica set +# behaviour (e.g. listening to configmaps changes), this will result in minimal running time and lowest resource +# consumption +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: my-replica-set-single +spec: + members: 1 + version: 4.0.15 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + persistent: false diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-upgrade.yaml b/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-upgrade.yaml new file mode 100644 index 000000000..561156058 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set-upgrade.yaml @@ -0,0 +1,16 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: my-replica-set +spec: + members: 3 + version: 4.0.15 + type: ReplicaSet + + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + persistent: true diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set.yaml b/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set.yaml new file mode 100644 index 000000000..c2f0ae0b8 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/fixtures/replica-set.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: my-replica-set +spec: + members: 3 + version: 4.0.20 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/replica_set.py b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set.py new file mode 100644 index 000000000..036ffe4e1 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set.py @@ -0,0 +1,568 @@ +import time +from typing import Dict + +import pytest +from kubernetes import client +from kubetester.automation_config_tester import AutomationConfigTester +from kubetester.kubetester import ( + fixture as yaml_fixture, + KubernetesTester, + skip_if_local, + fcv_from_version, +) +from kubetester.mongodb import MongoDB, Phase +from kubetester.mongotester import ReplicaSetTester +from kubetester import ( + assert_pod_container_security_context, + assert_pod_security_context, + create_or_update, +) +from pytest import fixture + +DEFAULT_BACKUP_VERSION = "11.12.0.7388-1" +DEFAULT_MONITORING_AGENT_VERSION = "11.12.0.7388-1" +RESOURCE_NAME = "my-replica-set" + + +def _get_group_id(envs) -> str: + for e in envs: + if e.name == "GROUP_ID": + return e.value + return "" + + +@fixture(scope="module") +def replica_set(namespace: str, custom_mdb_version: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set.yaml"), "my-replica-set", namespace + ) + resource.set_version(custom_mdb_version) + + # Setting podSpec shortcut values here to test they are still + # added as resources when needed. + resource["spec"]["podSpec"] = { + "podTemplate": { + "spec": { + "containers": [ + { + "name": "mongodb-enterprise-database", + "resources": { + "limits": { + "cpu": "1", + "memory": "1Gi", + }, + "requests": {"cpu": "0.2", "memory": "300M"}, + }, + } + ] + } + } + } + create_or_update(resource) + + return resource + + +@pytest.fixture(scope="class") +def config_version(): + class ConfigVersion: + def __init__(self): + self.version = 0 + + return ConfigVersion() + + +@pytest.mark.e2e_replica_set +class TestReplicaSetCreation(KubernetesTester): + def test_initialize_config_version(self, config_version): + self.ensure_group(self.get_om_org_id(), self.namespace) + config = self.get_automation_config() + config_version.version = config["version"] + + def test_mdb_created(self, replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running, timeout=400) + + def test_replica_set_sts_exists(self): + sts = self.appsv1.read_namespaced_stateful_set(RESOURCE_NAME, self.namespace) + assert sts + + def test_sts_creation(self): + sts = self.appsv1.read_namespaced_stateful_set(RESOURCE_NAME, self.namespace) + + assert sts.api_version == "apps/v1" + assert sts.kind == "StatefulSet" + assert sts.status.current_replicas == 3 + assert sts.status.ready_replicas == 3 + + def test_sts_metadata(self): + sts = self.appsv1.read_namespaced_stateful_set(RESOURCE_NAME, self.namespace) + + assert sts.metadata.name == RESOURCE_NAME + assert sts.metadata.labels["app"] == "my-replica-set-svc" + assert sts.metadata.namespace == self.namespace + owner_ref0 = sts.metadata.owner_references[0] + assert owner_ref0.api_version == "mongodb.com/v1" + assert owner_ref0.kind == "MongoDB" + assert owner_ref0.name == RESOURCE_NAME + + def test_sts_replicas(self): + sts = self.appsv1.read_namespaced_stateful_set(RESOURCE_NAME, self.namespace) + assert sts.spec.replicas == 3 + + def test_sts_template(self): + sts = self.appsv1.read_namespaced_stateful_set(RESOURCE_NAME, self.namespace) + + tmpl = sts.spec.template + assert tmpl.metadata.labels["app"] == "my-replica-set-svc" + assert tmpl.metadata.labels["controller"] == "mongodb-enterprise-operator" + assert tmpl.spec.service_account_name == "mongodb-enterprise-database-pods" + assert tmpl.spec.affinity.node_affinity is None + assert tmpl.spec.affinity.pod_affinity is None + assert tmpl.spec.affinity.pod_anti_affinity is not None + + def _get_pods(self, podname, qty=3): + return [podname.format(i) for i in range(qty)] + + def test_replica_set_pods_exists(self): + for podname in self._get_pods("my-replica-set-{}", 3): + pod = self.corev1.read_namespaced_pod(podname, self.namespace) + assert pod.metadata.name == podname + + def test_pods_are_running(self): + for podname in self._get_pods("my-replica-set-{}", 3): + pod = self.corev1.read_namespaced_pod(podname, self.namespace) + assert pod.status.phase == "Running" + + def test_pods_containers(self): + for podname in self._get_pods("my-replica-set-{}", 3): + pod = self.corev1.read_namespaced_pod(podname, self.namespace) + c0 = pod.spec.containers[0] + assert c0.name == "mongodb-enterprise-database" + + def test_pods_containers_ports(self): + for podname in self._get_pods("my-replica-set-{}", 3): + pod = self.corev1.read_namespaced_pod(podname, self.namespace) + c0 = pod.spec.containers[0] + assert c0.ports[0].container_port == 27017 + assert c0.ports[0].host_ip is None + assert c0.ports[0].host_port is None + assert c0.ports[0].protocol == "TCP" + + def test_pods_resources(self): + for podname in self._get_pods("my-replica-set-{}", 3): + pod = self.corev1.read_namespaced_pod(podname, self.namespace) + c0 = pod.spec.containers[0] + assert c0.resources.limits["cpu"] == "1" + assert c0.resources.limits["memory"] == "1Gi" + assert c0.resources.requests["cpu"] == "200m" + assert c0.resources.requests["memory"] == "300M" + + def test_pods_container_envvars(self): + for podname in self._get_pods("my-replica-set-{}", 3): + pod = self.corev1.read_namespaced_pod(podname, self.namespace) + c0 = pod.spec.containers[0] + for envvar in c0.env: + if envvar.name == "AGENT_API_KEY": + assert envvar.value is None, "cannot configure value and value_from" + assert ( + envvar.value_from.secret_key_ref.name + == f"{_get_group_id(c0.env)}-group-secret" + ) + assert envvar.value_from.secret_key_ref.key == "agentApiKey" + continue + + assert envvar.name in [ + "AGENT_FLAGS", + "BASE_URL", + "GROUP_ID", + "USER_LOGIN", + "LOG_LEVEL", + "SSL_TRUSTED_MMS_SERVER_CERTIFICATE", + "SSL_REQUIRE_VALID_MMS_CERTIFICATES", + "MULTI_CLUSTER_MODE", + ] + assert envvar.value is not None or envvar.name == "AGENT_FLAGS" + + def test_service_is_created(self): + svc = self.corev1.read_namespaced_service("my-replica-set-svc", self.namespace) + assert svc + + def test_clusterip_service_exists(self): + """Test that replica set is not exposed externally.""" + services = self.clients("corev1").list_namespaced_service( + self.get_namespace(), + label_selector="controller=mongodb-enterprise-operator", + ) + + # 1 for replica set + assert len(services.items) == 1 + assert services.items[0].spec.type == "ClusterIP" + + def test_security_context_pods(self, operator_installation_config: Dict[str, str]): + + managed = operator_installation_config["managedSecurityContext"] == "true" + for podname in self._get_pods("my-replica-set-{}", 3): + pod = self.corev1.read_namespaced_pod(podname, self.namespace) + assert_pod_security_context(pod, managed) + assert_pod_container_security_context(pod.spec.containers[0], managed) + + @skip_if_local + def test_security_context_operator( + self, operator_installation_config: Dict[str, str] + ): + # todo there should be a better way to find the pods for deployment + response = self.corev1.list_namespaced_pod(self.namespace) + operator_pod = [ + pod + for pod in response.items + if pod.metadata.name.startswith("mongodb-enterprise-operator-") + ][0] + security_context = operator_pod.spec.security_context + if operator_installation_config["managedSecurityContext"] == "false": + assert security_context.run_as_user == 2000 + assert security_context.run_as_non_root + assert security_context.fs_group is None + else: + # Note, that this code is a bit fragile as may depend on the version of Openshift, but we need to verify + # that this is not "our" security context but the one generated by Openshift + assert security_context.run_as_user is None + assert security_context.run_as_non_root is None + assert security_context.se_linux_options is not None + assert security_context.fs_group is not None + assert security_context.fs_group != 2000 + + def test_om_processes_are_created(self): + config = self.get_automation_config() + assert len(config["processes"]) == 3 + + def test_om_replica_set_is_created(self): + config = self.get_automation_config() + assert len(config["replicaSets"]) == 1 + + def test_om_processes(self, custom_mdb_version: str): + config = self.get_automation_config() + processes = config["processes"] + + for idx in range(0, 2): + name = f"my-replica-set-{idx}" + p = processes[idx] + assert p["name"] == name + assert p["processType"] == "mongod" + assert p["version"] == custom_mdb_version + assert p["authSchemaVersion"] == 5 + assert p["featureCompatibilityVersion"] == fcv_from_version( + custom_mdb_version + ) + assert p["hostname"] == "{}.my-replica-set-svc.{}.svc.cluster.local".format( + name, self.namespace + ) + assert p["args2_6"]["net"]["port"] == 27017 + assert p["args2_6"]["replication"]["replSetName"] == RESOURCE_NAME + assert p["args2_6"]["storage"]["dbPath"] == "/data" + assert p["args2_6"]["systemLog"]["destination"] == "file" + assert ( + p["args2_6"]["systemLog"]["path"] + == "/var/log/mongodb-mms-automation/mongodb.log" + ) + assert p["logRotate"]["sizeThresholdMB"] == 1000 + assert p["logRotate"]["timeThresholdHrs"] == 24 + + def test_om_replica_set(self): + config = self.get_automation_config() + rs = config["replicaSets"] + assert rs[0]["_id"] == RESOURCE_NAME + + for idx in range(0, 2): + m = rs[0]["members"][idx] + assert m["_id"] == idx + assert m["arbiterOnly"] is False + assert m["hidden"] is False + assert m["buildIndexes"] is True + assert m["host"] == f"my-replica-set-{idx}" + assert m["votes"] == 1 + assert m["priority"] == 1.0 + + def test_monitoring_versions(self): + config = self.get_automation_config() + mv = config["monitoringVersions"] + assert len(mv) == 3 + + # Monitoring agent is installed on all hosts + for i in range(0, 3): + # baseUrl is not present in Cloud Manager response + if "baseUrl" in mv[i]: + assert mv[i]["baseUrl"] is None + hostname = ( + "my-replica-set-{}.my-replica-set-svc.{}.svc.cluster.local".format( + i, self.namespace + ) + ) + assert mv[i]["hostname"] == hostname + assert mv[i]["name"] == DEFAULT_MONITORING_AGENT_VERSION + + def test_backup(self): + config = self.get_automation_config() + # 1 backup agent per host + bkp = config["backupVersions"] + assert len(bkp) == 3 + + # Backup agent is installed on all hosts + for i in range(0, 3): + hostname = ( + "my-replica-set-{}.my-replica-set-svc.{}.svc.cluster.local".format( + i, self.namespace + ) + ) + assert bkp[i]["hostname"] == hostname + assert bkp[i]["name"] == DEFAULT_BACKUP_VERSION + + def test_proper_automation_config_version(self, config_version): + config = self.get_automation_config() + # We create 3 members of the replicaset here, so there will be 2 changes. + # Anything more than 2 changes indicates that we're sending more things to the Ops/Cloud Manager than we should. + assert (config["version"] - config_version.version) == 2 + + @skip_if_local + def test_replica_set_was_configured(self): + ReplicaSetTester(RESOURCE_NAME, 3, ssl=False).assert_connectivity() + + @skip_if_local + def test_replica_set_was_configured_with_srv(self): + ReplicaSetTester(RESOURCE_NAME, 3, ssl=False, srv=True).assert_connectivity() + + +@pytest.mark.e2e_replica_set +def test_replica_set_can_be_scaled_to_single_member(replica_set: MongoDB): + """Scaling to 1 member somehow changes the way the Replica Set is represented and there + will be no more a "Primary" or "Secondaries" in the client, so the test does not check + Replica Set state. An additional test `test_replica_set_can_be_scaled_down_and_connectable` + scales down to 3 (from 5) and makes sure the Replica is connectable with "Primary" and + "Secondaries" set.""" + replica_set["spec"]["members"] = 1 + replica_set.update() + + replica_set.assert_reaches_phase(Phase.Running, timeout=1200) + + actester = AutomationConfigTester(KubernetesTester.get_automation_config()) + + # we should have only 1 process on the replica-set + assert len(actester.get_replica_set_processes(replica_set.name)) == 1 + + assert replica_set["status"]["members"] == 1 + + replica_set.assert_connectivity() + + +@pytest.mark.e2e_replica_set +class TestReplicaSetScaleUp(KubernetesTester): + def test_mdb_updated(self, replica_set: MongoDB): + replica_set["spec"]["members"] = 5 + replica_set.update() + replica_set.assert_reaches_phase(Phase.Running, timeout=500) + + def test_replica_set_sts_should_exist(self): + sts = self.appsv1.read_namespaced_stateful_set(RESOURCE_NAME, self.namespace) + assert sts + + def test_sts_update(self): + sts = self.appsv1.read_namespaced_stateful_set(RESOURCE_NAME, self.namespace) + + assert sts.api_version == "apps/v1" + assert sts.kind == "StatefulSet" + assert sts.status.current_replicas == 5 + assert sts.status.ready_replicas == 5 + + def test_sts_metadata(self): + sts = self.appsv1.read_namespaced_stateful_set(RESOURCE_NAME, self.namespace) + + assert sts.metadata.name == RESOURCE_NAME + assert sts.metadata.labels["app"] == "my-replica-set-svc" + assert sts.metadata.namespace == self.namespace + owner_ref0 = sts.metadata.owner_references[0] + assert owner_ref0.api_version == "mongodb.com/v1" + assert owner_ref0.kind == "MongoDB" + assert owner_ref0.name == RESOURCE_NAME + + def test_sts_replicas(self): + sts = self.appsv1.read_namespaced_stateful_set(RESOURCE_NAME, self.namespace) + assert sts.spec.replicas == 5 + + def _get_pods(self, podname, qty): + return [podname.format(i) for i in range(qty)] + + def test_replica_set_pods_exists(self): + for podname in self._get_pods("my-replica-set-{}", 5): + pod = self.corev1.read_namespaced_pod(podname, self.namespace) + assert pod.metadata.name == podname + + def test_pods_are_running(self): + for podname in self._get_pods("my-replica-set-{}", 5): + pod = self.corev1.read_namespaced_pod(podname, self.namespace) + assert pod.status.phase == "Running" + + def test_pods_containers(self): + for podname in self._get_pods("my-replica-set-{}", 5): + pod = self.corev1.read_namespaced_pod(podname, self.namespace) + c0 = pod.spec.containers[0] + assert c0.name == "mongodb-enterprise-database" + + def test_pods_containers_ports(self): + for podname in self._get_pods("my-replica-set-{}", 5): + pod = self.corev1.read_namespaced_pod(podname, self.namespace) + c0 = pod.spec.containers[0] + assert c0.ports[0].container_port == 27017 + assert c0.ports[0].host_ip is None + assert c0.ports[0].host_port is None + assert c0.ports[0].protocol == "TCP" + + def test_pods_container_envvars(self): + for podname in self._get_pods("my-replica-set-{}", 5): + pod = self.corev1.read_namespaced_pod(podname, self.namespace) + c0 = pod.spec.containers[0] + for envvar in c0.env: + if envvar.name == "AGENT_API_KEY": + assert envvar.value is None, "cannot configure value and value_from" + assert ( + envvar.value_from.secret_key_ref.name + == f"{_get_group_id(c0.env)}-group-secret" + ) + assert envvar.value_from.secret_key_ref.key == "agentApiKey" + continue + + assert envvar.name in [ + "AGENT_FLAGS", + "BASE_URL", + "GROUP_ID", + "USER_LOGIN", + "LOG_LEVEL", + "SSL_TRUSTED_MMS_SERVER_CERTIFICATE", + "SSL_REQUIRE_VALID_MMS_CERTIFICATES", + "MULTI_CLUSTER_MODE", + ] + assert envvar.value is not None or envvar.name == "AGENT_FLAGS" + + def test_service_is_created(self): + svc = self.corev1.read_namespaced_service("my-replica-set-svc", self.namespace) + assert svc + + def test_om_processes_are_created(self): + config = self.get_automation_config() + assert len(config["processes"]) == 5 + + def test_om_replica_set_is_created(self): + config = self.get_automation_config() + assert len(config["replicaSets"]) == 1 + + def test_om_processes(self, custom_mdb_version: str): + config = self.get_automation_config() + processes = config["processes"] + for idx in range(0, 4): + name = f"my-replica-set-{idx}" + p = processes[idx] + assert p["name"] == name + assert p["processType"] == "mongod" + assert p["version"] == custom_mdb_version + assert p["authSchemaVersion"] == 5 + assert p["featureCompatibilityVersion"] == fcv_from_version( + custom_mdb_version + ) + assert p["hostname"] == "{}.my-replica-set-svc.{}.svc.cluster.local".format( + name, self.namespace + ) + assert p["args2_6"]["net"]["port"] == 27017 + assert p["args2_6"]["replication"]["replSetName"] == RESOURCE_NAME + assert p["args2_6"]["storage"]["dbPath"] == "/data" + assert p["args2_6"]["systemLog"]["destination"] == "file" + assert ( + p["args2_6"]["systemLog"]["path"] + == "/var/log/mongodb-mms-automation/mongodb.log" + ) + assert p["logRotate"]["sizeThresholdMB"] == 1000 + assert p["logRotate"]["timeThresholdHrs"] == 24 + + def test_om_replica_set(self): + config = self.get_automation_config() + rs = config["replicaSets"] + assert rs[0]["_id"] == RESOURCE_NAME + + for idx in range(0, 4): + m = rs[0]["members"][idx] + assert m["_id"] == idx + assert m["arbiterOnly"] is False + assert m["hidden"] is False + assert m["priority"] == 1.0 + assert m["votes"] == 1 + assert m["buildIndexes"] is True + assert m["host"] == f"my-replica-set-{idx}" + + def test_monitoring_versions(self): + config = self.get_automation_config() + mv = config["monitoringVersions"] + assert len(mv) == 5 + + # Monitoring agent is installed on all hosts + for i in range(0, 5): + if "baseUrl" in mv[i]: + assert mv[i]["baseUrl"] is None + hostname = ( + "my-replica-set-{}.my-replica-set-svc.{}.svc.cluster.local".format( + i, self.namespace + ) + ) + assert mv[i]["hostname"] == hostname + assert mv[i]["name"] == DEFAULT_MONITORING_AGENT_VERSION + + def test_backup(self): + config = self.get_automation_config() + # 1 backup agent per host + bkp = config["backupVersions"] + assert len(bkp) == 5 + + # Backup agent is installed on all hosts + for i in range(0, 5): + hostname = "{resource_name}-{idx}.{resource_name}-svc.{namespace}.svc.cluster.local".format( + resource_name=RESOURCE_NAME, idx=i, namespace=self.namespace + ) + assert bkp[i]["hostname"] == hostname + assert bkp[i]["name"] == DEFAULT_BACKUP_VERSION + + +@pytest.mark.e2e_replica_set +def test_replica_set_can_be_scaled_down_and_connectable(replica_set: MongoDB): + """Makes sure that scaling down 5->3 members still reaches a Running & connectable state.""" + replica_set["spec"]["members"] = 3 + replica_set.update() + + replica_set.assert_reaches_phase(Phase.Running, timeout=1000) + + actester = AutomationConfigTester(KubernetesTester.get_automation_config()) + + assert len(actester.get_replica_set_processes(RESOURCE_NAME)) == 3 + + assert replica_set["status"]["members"] == 3 + + replica_set.assert_connectivity() + + +@pytest.mark.e2e_replica_set +class TestReplicaSetDelete(KubernetesTester): + """ + name: Replica Set Deletion + tags: replica-set, removal + description: | + Deletes a Replica Set. + delete: + file: replica-set.yaml + wait_until: mongo_resource_deleted + """ + + def test_replica_set_sts_doesnt_exist(self): + """The StatefulSet must be removed by Kubernetes as soon as the MongoDB resource is removed. + Note, that this may lag sometimes (caching or whatever?) and it's more safe to wait a bit""" + time.sleep(15) + with pytest.raises(client.rest.ApiException): + self.appsv1.read_namespaced_stateful_set(RESOURCE_NAME, self.namespace) + + def test_service_does_not_exist(self): + with pytest.raises(client.rest.ApiException): + self.corev1.read_namespaced_service(RESOURCE_NAME + "-svc", self.namespace) diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_agent_flags.py b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_agent_flags.py new file mode 100644 index 000000000..de4ae9eaa --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_agent_flags.py @@ -0,0 +1,38 @@ +from pytest import mark, fixture + +from kubetester import find_fixture + +from kubetester.mongodb import MongoDB, Phase + +from kubetester.kubetester import KubernetesTester + + +@fixture(scope="module") +def replica_set(namespace: str) -> MongoDB: + resource = MongoDB.from_yaml(find_fixture("replica-set-basic.yaml"), namespace=namespace) + + resource["spec"]["agent"] = {"startupOptions": {"logFile": "/var/log/mongodb-mms-automation/customLogFile"}} + resource["spec"]["version"] = "4.4.0" + + return resource.create() + + +@mark.e2e_replica_set_agent_flags +def test_replica_set(replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running, timeout=400) + + +@mark.e2e_replica_set_agent_flags +def test_replica_set_has_agent_flags(replica_set: MongoDB, namespace: str): + cmd = [ + "/bin/sh", + "-c", + "ls /var/log/mongodb-mms-automation/customLogFile* | wc -l", + ] + for i in range(3): + result = KubernetesTester.run_command_in_pod_container( + f"replica-set-{i}", + namespace, + cmd, + ) + assert result != "0" diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_config_map.py b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_config_map.py new file mode 100644 index 000000000..2d765d574 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_config_map.py @@ -0,0 +1,29 @@ +import pytest + +from kubernetes.client import V1ConfigMap +from kubetester.kubetester import KubernetesTester + + +@pytest.mark.e2e_replica_set_config_map +class TestReplicaSetListensConfigMap(KubernetesTester): + """ + name: ReplicaSet tracks configmap changes + description: | + Creates a replicaSet, then changes configmap adds rubbish orgId and checks that the reconciliation for the | + standalone happened and it got into Failed state. Note, that this test cannot be run with 'make e2e .. light=true' | + flag locally as config map must be recreated + create: + file: replica-set-single.yaml + wait_until: in_running_state + timeout: 120 + """ + + def test_patch_config_map(self): + config_map = V1ConfigMap(data={"orgId": "wrongId"}) + self.clients("corev1").patch_namespaced_config_map( + "my-project", self.get_namespace(), config_map + ) + + print('Patched the ConfigMap - changed orgId to "wrongId"') + + KubernetesTester.wait_until("in_error_state", 20) diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_custom_podspec.py b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_custom_podspec.py new file mode 100644 index 000000000..03c542b16 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_custom_podspec.py @@ -0,0 +1,144 @@ +from pytest import fixture, mark +from kubetester.kubetester import fixture as yaml_fixture, KubernetesTester +from kubetester.mongodb import MongoDB, Phase +from kubetester.custom_podspec import assert_stateful_set_podspec + + +@fixture(scope="module") +def replica_set(namespace: str, custom_mdb_version: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-custom-podspec.yaml"), namespace=namespace + ) + resource.set_version(custom_mdb_version) + yield resource.create() + + +@mark.e2e_replica_set_custom_podspec +def test_replica_set_reaches_running_phase(replica_set): + replica_set.assert_reaches_phase(Phase.Running, timeout=600) + + +@mark.e2e_replica_set_custom_podspec +def test_stateful_set_spec_updated(replica_set, namespace): + appsv1 = KubernetesTester.clients("appsv1") + sts = appsv1.read_namespaced_stateful_set(replica_set.name, namespace) + containers_spec = [ + { + "name": "mongodb-enterprise-database", + "resources": { + "limits": { + "cpu": "2", + }, + "requests": { + "cpu": "1", + }, + }, + "volume_mounts": [ + { + "name": "database-scripts", + "mount_path": "/opt/scripts", + "sub_path": None, + "sub_path_expr": None, + "mount_propagation": None, + "read_only": True, + }, + { + "name": "test-volume", + "mount_path": "/somewhere", + "sub_path": None, + "sub_path_expr": None, + "mount_propagation": None, + "read_only": None, + }, + { + "name": "agent-api-key", + "mount_path": "/mongodb-automation/agent-api-key", + "sub_path": None, + "sub_path_expr": None, + "mount_propagation": None, + "read_only": None, + }, + { + "name": "data", + "mount_path": "/data", + "sub_path": "data", + "sub_path_expr": None, + "mount_propagation": None, + "read_only": None, + }, + { + "name": "data", + "mount_path": "/journal", + "sub_path": "journal", + "sub_path_expr": None, + "mount_propagation": None, + "read_only": None, + }, + { + "name": "data", + "mount_path": "/var/log/mongodb-mms-automation", + "sub_path": "logs", + "sub_path_expr": None, + "mount_propagation": None, + "read_only": None, + }, + { + "name": "agent", + "mount_path": "/tmp", + "sub_path": "tmp", + "sub_path_expr": None, + "mount_propagation": None, + "read_only": None, + }, + { + "name": "agent", + "mount_path": "/var/lib/mongodb-mms-automation", + "sub_path": "mongodb-mms-automation", + "sub_path_expr": None, + "mount_propagation": None, + "read_only": None, + }, + { + "name": "agent", + "mount_path": "/mongodb-automation", + "sub_path": "mongodb-automation", + "sub_path_expr": None, + "mount_propagation": None, + "read_only": None, + }, + ], + }, + { + "name": "side-car", + "image": "busybox:latest", + "volume_mounts": [ + { + "mount_path": "/somewhere", + "name": "test-volume", + "sub_path": None, + "sub_path_expr": None, + "mount_propagation": None, + "read_only": None, + } + ], + "command": ["/bin/sh"], + "args": ["-c", "echo ok > /somewhere/busybox_file && sleep infinity"], + }, + ] + + assert_stateful_set_podspec( + sts.spec.template.spec, + weight=50, + topology_key="mykey-rs", + grace_period_seconds=30, + containers_spec=containers_spec, + ) + + host_aliases = sts.spec.template.spec.host_aliases + alias = host_aliases[0] + + assert len(host_aliases) == 1 + assert alias.ip == "1.2.3.4" + assert alias.hostnames[0] == "hostname" + assert len(sts.spec.template.metadata.annotations) == 1 + assert sts.spec.template.metadata.annotations["key1"] == "val1" diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_custom_sa.py b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_custom_sa.py new file mode 100644 index 000000000..98598b8fe --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_custom_sa.py @@ -0,0 +1,45 @@ +from pytest import fixture, mark +from kubetester import create_service_account, delete_service_account +from kubetester.mongodb import MongoDB, Phase +from kubetester.kubetester import KubernetesTester, fixture as yaml_fixture + + +@fixture(scope="module") +def create_custom_sa(namespace: str) -> str: + return create_service_account(namespace=namespace, name="test-sa") + + +@fixture(scope="module") +def replica_set( + namespace: str, custom_mdb_version: str, create_custom_sa: str +) -> MongoDB: + resource = MongoDB.from_yaml(yaml_fixture("replica-set.yaml"), namespace=namespace) + + resource["spec"]["podSpec"] = { + "podTemplate": {"spec": {"serviceAccountName": "test-sa"}} + } + resource["spec"]["statefulSet"] = {"spec": {"serviceName": "rs-svc"}} + resource.set_version(custom_mdb_version) + yield resource.create() + # teardown, delete the custom service-account + delete_service_account(namespace=namespace, name="test-sa") + + +@mark.e2e_replica_set_custom_sa +def test_replica_set_reaches_running_phase(replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running, timeout=600) + + +@mark.e2e_replica_set_custom_sa +def test_stateful_set_spec_service_account(replica_set: MongoDB, namespace: str): + appsv1 = KubernetesTester.clients("appsv1") + sts = appsv1.read_namespaced_stateful_set(replica_set.name, namespace) + + assert sts.spec.template.spec.service_account_name == "test-sa" + + +@mark.e2e_replica_set_custom_sa +def test_service_is_created(namespace: str): + corev1 = KubernetesTester.clients("corev1") + svc = corev1.read_namespaced_service("rs-svc", namespace) + assert svc diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_exposed_externally.py b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_exposed_externally.py new file mode 100644 index 000000000..67939eb02 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_exposed_externally.py @@ -0,0 +1,66 @@ +import pytest + +from pytest import fixture +from kubernetes import client + +from kubetester import create_or_update +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.mongodb import MongoDB, Phase + + +@fixture(scope="module") +def replica_set(namespace: str, custom_mdb_version: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-externally-exposed.yaml"), + "my-replica-set-externally-exposed", + namespace, + ) + resource["spec"]["members"] = 2 + resource.set_version(custom_mdb_version) + create_or_update(resource) + return resource + + +@pytest.mark.e2e_replica_set_exposed_externally +def test_replica_set_created(replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running, timeout=300) + + +def test_service_exists(namespace: str): + for i in range(2): + service = client.CoreV1Api().read_namespaced_service( + f"my-replica-set-externally-exposed-{i}-svc-external", namespace + ) + assert service.spec.type == "LoadBalancer" + assert service.spec.ports[0].port == 27017 + + +@pytest.mark.e2e_replica_set_exposed_externally +def test_service_node_port_stays_the_same(namespace: str, replica_set: MongoDB): + service = client.CoreV1Api().read_namespaced_service("my-replica-set-externally-exposed-0-svc-external", namespace) + node_port = service.spec.ports[0].node_port + + replica_set.load() + replica_set["spec"]["members"] = 3 + replica_set.update() + + replica_set.assert_abandons_phase(Phase.Running, timeout=60) + replica_set.assert_reaches_phase(Phase.Running, timeout=300) + + service = client.CoreV1Api().read_namespaced_service("my-replica-set-externally-exposed-0-svc-external", namespace) + assert service.spec.type == "LoadBalancer" + assert service.spec.ports[0].node_port == node_port + + +@pytest.mark.e2e_replica_set_exposed_externally +def test_service_gets_deleted(replica_set: MongoDB, namespace: str): + replica_set.load() + last_transition = replica_set.get_status_last_transition_time() + replica_set["spec"]["exposedExternally"] = False + replica_set.update() + + replica_set.assert_state_transition_happens(last_transition) + replica_set.assert_reaches_phase(Phase.Running, timeout=300) + for i in range(replica_set["spec"]["members"]): + with pytest.raises(client.rest.ApiException): + client.CoreV1Api().read_namespaced_service(f"my-replica-set-externally-exposed-{i}-svc-external", namespace) diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_groups.py b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_groups.py new file mode 100644 index 000000000..016c6841d --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_groups.py @@ -0,0 +1,145 @@ +import pytest + +from kubetester.kubetester import ( + KubernetesTester, + fixture, + EXTERNALLY_MANAGED_TAG, + MAX_TAG_LEN, +) +from kubetester.omtester import should_include_tag + + +@pytest.mark.e2e_replica_set_groups +class TestReplicaSetOrganizationsPagination(KubernetesTester): + """ + name: Test for configuration when organization id is not specified but the organization exists already + description: | + Both organization and group already exist. + Two things are tested: + 1. organization id is not specified in config map but the organization with the same name already exists + so the Operator will find it by name + 2. the group already exists so no new group will be created + The test is skipped for cloud manager as we cannot create organizations there ("API_KEY_CANNOT_CREATE_ORG") + """ + + all_orgs_ids = [] + all_groups_ids = [] + org_id = None + group_name = None + + @classmethod + def setup_env(cls): + # Create 5 organizations + cls.all_orgs_ids = cls.create_organizations(5) + + # Create another organization with the same name as group + cls.group_name = KubernetesTester.random_k8s_name("replica-set-group-test-") + cls.org_id = cls.create_organization(cls.group_name) + + # Create 5 groups inside the organization + cls.all_groups_ids = cls.create_groups(cls.org_id, 5) + + # Create the group manually (btw no tag will be set - this must be fixed by the Operator) + cls.create_group(cls.org_id, cls.group_name) + + # Update the config map - change the group name, no orgId + cls.patch_config_map( + cls.get_namespace(), + "my-project", + {"projectName": cls.group_name, "orgId": ""}, + ) + + print( + 'Patched config map, now it has the projectName "{}"'.format(cls.group_name) + ) + + def test_standalone_created_organization_found(self): + groups_in_org = self.get_groups_in_organization_first_page( + self.__class__.org_id + )["totalCount"] + + # Create a replica set - both the organization and the group will be found (after traversing pages) + self.create_custom_resource_from_file( + self.get_namespace(), fixture("replica-set-single.yaml") + ) + KubernetesTester.wait_until("in_running_state", 150) + + # Making sure no more groups and organizations were created, but the tag was fixed by the Operator + assert len(self.find_organizations(self.__class__.group_name)) == 1 + print( + 'Only one organization with name "{}" exists (as expected)'.format( + self.__class__.group_name + ) + ) + + assert ( + self.get_groups_in_organization_first_page(self.__class__.org_id)[ + "totalCount" + ] + == groups_in_org + ) + group = self.query_group(self.__class__.group_name) + assert group is not None + assert group["orgId"] == self.__class__.org_id + + version = KubernetesTester.om_version() + expected_tags = [self.namespace[:MAX_TAG_LEN].upper()] + + if should_include_tag(version): + expected_tags.append(EXTERNALLY_MANAGED_TAG) + + assert sorted(group["tags"]) == sorted(expected_tags) + + print( + 'Only one group with name "{}" exists (as expected)'.format( + self.__class__.group_name + ) + ) + + @staticmethod + def create_organizations(count): + ids = [] + for i in range(0, count): + ids.append( + KubernetesTester.create_organization( + KubernetesTester.random_k8s_name("fake-{}-".format(i)) + ) + ) + if (i + 1) % 100 == 0: + print("Created {} fake organizations".format(i + 1)) + return ids + + @staticmethod + def create_groups(org_id, count): + ids = [] + for i in range(0, count): + ids.append( + KubernetesTester.create_group( + org_id, KubernetesTester.random_k8s_name("fake-group-{}-".format(i)) + ) + ) + if (i + 1) % 100 == 0: + print( + "Created {} fake groups inside organization {}".format( + i + 1, org_id + ) + ) + return ids + + @classmethod + def teardown_env(cls): + # Remove fake organizations from Ops Manager, except for the organization that was used by the standalone + for i, id in enumerate(cls.all_orgs_ids): + cls.remove_organization(id) + if (i + 1) % 100 == 0: + print("Removed {} fake organizations".format(i)) + + # Remove fake groups from Ops Manager + for i, id in enumerate(cls.all_groups_ids): + cls.remove_group(id) + if (i + 1) % 100 == 0: + print( + "Removed {} fake groups inside organizationn {}".format( + i, cls.org_id + ) + ) diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_liveness_probe.py b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_liveness_probe.py new file mode 100644 index 000000000..9c86b6b2f --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_liveness_probe.py @@ -0,0 +1,126 @@ +import time +from typing import Set +import pytest + +from kubetester import create_or_update +from kubetester.kubetester import ( + fixture as yaml_fixture, + KubernetesTester, +) +from kubetester.mongodb import MongoDB +from pytest import fixture +from kubernetes import client + + +@fixture(scope="module") +def replica_set(namespace: str, custom_mdb_version: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-liveness.yaml"), "my-replica-set", namespace + ) + + create_or_update(resource) + + return resource + + +def _get_pods(podname_template: str, qty: int = 3): + return [podname_template.format(i) for i in range(qty)] + + +@pytest.mark.e2e_replica_set_liveness_probe +def test_pods_are_running(replica_set: MongoDB, namespace: str): + corev1_client = client.CoreV1Api() + running_pods: Set[str] = set() + tries = 10 + # Wait for all the pods to be running + # We can't wait for the replica set to be running + # as it will never get to it (mongod is not starting) + while tries: + if len(running_pods) == 3: + break + for podname in _get_pods("my-replica-set-{}", 3): + try: + pod = corev1_client.read_namespaced_pod(podname, namespace) + if pod.status.phase == "Running": + running_pods.add(podname) + except: + # Pod not found, will retry + pass + tries -= 1 + time.sleep(30) + assert len(running_pods) == 3 + + +@pytest.mark.e2e_replica_set_liveness_probe +def test_no_pods_get_restarted(replica_set: MongoDB, namespace: str): + corev1_client = client.CoreV1Api() + statefulset_liveness_probe = ( + replica_set.read_statefulset().spec.template.spec.containers[0].liveness_probe + ) + failure_threshold = statefulset_liveness_probe.failure_threshold + period_seconds = statefulset_liveness_probe.period_seconds + + # Leave some extra time after the failure threshold just to be sure + time.sleep(failure_threshold * period_seconds + 20) + for podname in _get_pods("my-replica-set-{}", 3): + pod = corev1_client.read_namespaced_pod(podname, namespace) + + # Pods should not restart because of a missing mongod process + assert pod.status.container_statuses[0].restart_count == 0 + + +@pytest.mark.e2e_replica_set_liveness_probe +def test_pods_are_restarted_if_agent_process_is_terminated( + replica_set: MongoDB, namespace: str +): + corev1_client = client.CoreV1Api() + + agent_pid_file = "/mongodb-automation/mongodb-mms-automation-agent.pid" + pid_cmd = ["cat", agent_pid_file] + # Get the agent's PID + agent_pid = KubernetesTester.run_command_in_pod_container( + "my-replica-set-0", namespace, pid_cmd + ) + + # Kill the agent using its PID + kill_cmd = ["kill", "-s", "SIGTERM", agent_pid.strip()] + KubernetesTester.run_command_in_pod_container( + "my-replica-set-0", namespace, kill_cmd + ) + + # Ensure agent's pid file still exists. + # This is to simulate not graceful kill, e.g. by OOM killer + agent_pid_2 = KubernetesTester.run_command_in_pod_container( + "my-replica-set-0", namespace, pid_cmd + ) + + assert agent_pid == agent_pid_2 + + statefulset_liveness_probe = ( + replica_set.read_statefulset().spec.template.spec.containers[0].liveness_probe + ) + failure_threshold = statefulset_liveness_probe.failure_threshold + period_seconds = statefulset_liveness_probe.period_seconds + time.sleep(failure_threshold * period_seconds + 20) + + # Pod zero should have restarted, because the agent was killed + assert ( + corev1_client.read_namespaced_pod("my-replica-set-0", namespace) + .status.container_statuses[0] + .restart_count + > 0 + ) + + # Pods 1 and 2 should not have restarted, because the agent is intact + assert ( + corev1_client.read_namespaced_pod("my-replica-set-1", namespace) + .status.container_statuses[0] + .restart_count + == 0 + ) + assert ( + corev1_client.read_namespaced_pod("my-replica-set-2", namespace) + .status.container_statuses[0] + .restart_count + == 0 + ) diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_member_options.py b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_member_options.py new file mode 100644 index 000000000..3de747dbe --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_member_options.py @@ -0,0 +1,161 @@ +import pytest + +from kubetester.kubetester import ( + fixture as yaml_fixture, + skip_if_local, +) +from kubetester.mongodb import MongoDB, Phase +from kubetester import create_or_update +from pytest import fixture + +RESOURCE_NAME = "my-replica-set" + + +@fixture(scope="module") +def replica_set(namespace: str, custom_mdb_version: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set.yaml"), RESOURCE_NAME, namespace + ) + resource.set_version(custom_mdb_version) + resource["spec"]["memberConfig"] = [ + { + "votes": 1, + "priority": "0.5", + "tags": { + "tag1": "value1", + "environment": "prod", + }, + }, + { + "votes": 1, + "priority": "1.5", + "tags": { + "tag2": "value2", + "environment": "prod", + }, + }, + { + "votes": 1, + "priority": "0.5", + "tags": { + "tag2": "value2", + "environment": "prod", + }, + }, + ] + create_or_update(resource) + + return resource + + +@pytest.mark.e2e_replica_set_member_options +def test_replica_set_created(replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running) + + +@pytest.mark.e2e_replica_set_member_options +def test_replica_set_member_options_ac(replica_set: MongoDB): + replica_set.load() + + config = replica_set.get_automation_config_tester().automation_config + rs = config["replicaSets"] + + member1 = rs[0]["members"][0] + member2 = rs[0]["members"][1] + member3 = rs[0]["members"][2] + + assert member1["votes"] == 1 + assert member1["priority"] == 0.5 + assert member1["tags"] == {"tag1": "value1", "environment": "prod"} + + assert member2["votes"] == 1 + assert member2["priority"] == 1.5 + assert member2["tags"] == {"tag2": "value2", "environment": "prod"} + + assert member3["votes"] == 1 + assert member3["priority"] == 0.5 + assert member3["tags"] == {"tag2": "value2", "environment": "prod"} + + +@pytest.mark.e2e_replica_set_member_options +def test_replica_set_update_member_options(replica_set: MongoDB): + replica_set.load() + + replica_set["spec"]["memberConfig"][0] = { + "votes": 1, + "priority": "2.5", + "tags": { + "tag1": "value1", + "tag2": "value2", + "environment": "prod", + }, + } + replica_set.update() + replica_set.assert_reaches_phase(Phase.Running) + + config = replica_set.get_automation_config_tester().automation_config + rs = config["replicaSets"] + + updated_member = rs[0]["members"][0] + assert updated_member["votes"] == 1 + assert updated_member["priority"] == 2.5 + assert updated_member["tags"] == { + "tag1": "value1", + "tag2": "value2", + "environment": "prod", + } + + +@pytest.mark.e2e_replica_set_member_options +def test_replica_set_member_votes_to_0(replica_set: MongoDB): + replica_set.load() + + # A non-voting member must also have priority set to 0 + replica_set["spec"]["memberConfig"][1]["votes"] = 0 + replica_set["spec"]["memberConfig"][1]["priority"] = "0.0" + replica_set.update() + replica_set.assert_reaches_phase(Phase.Running) + + config = replica_set.get_automation_config_tester().automation_config + rs = config["replicaSets"] + + updated_member = rs[0]["members"][1] + assert updated_member["votes"] == 0 + assert updated_member["priority"] == 0.0 + + +@pytest.mark.e2e_replica_set_member_options +def test_replica_set_invalid_votes_and_priority(replica_set: MongoDB): + replica_set.load() + # A member with 0 votes must also have priority 0.0 + replica_set["spec"]["memberConfig"][1]["votes"] = 0 + replica_set["spec"]["memberConfig"][1]["priority"] = "1.2" + replica_set.update() + replica_set.assert_reaches_phase( + Phase.Failed, + msg_regexp="Failed to create/update \(Ops Manager reconciliation phase\).*cannot have 0 votes when priority is greater than 0", + ) + + +@pytest.mark.e2e_replica_set_member_options +def test_replica_set_recover_valid_member_options(replica_set: MongoDB): + replica_set.load() + # A member with priority 0.0 could still be a voting member. It cannot become primary and cannot trigger elections. + # https://www.mongodb.com/docs/v5.0/core/replica-set-priority-0-member/#priority-0-replica-set-members + replica_set["spec"]["memberConfig"][1]["votes"] = 1 + replica_set["spec"]["memberConfig"][1]["priority"] = "0.0" + replica_set.update() + replica_set.assert_reaches_phase(Phase.Running) + + +@pytest.mark.e2e_replica_set_member_options +def test_replica_set_only_one_vote_per_member(replica_set: MongoDB): + replica_set.load() + # A single voting member can only have 1 vote + replica_set["spec"]["memberConfig"][2]["votes"] = 5 + replica_set["spec"]["memberConfig"][2]["priority"] = "5.8" + replica_set.update() + replica_set.assert_reaches_phase( + Phase.Failed, + msg_regexp="Failed to create/update \(Ops Manager reconciliation phase\).*cannot have greater than 1 vote", + ) diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_mongod_options.py b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_mongod_options.py new file mode 100644 index 000000000..acc27cdb2 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_mongod_options.py @@ -0,0 +1,94 @@ +from pytest import fixture, mark + +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.mongodb import MongoDB, Phase + + +@fixture(scope="module") +def replica_set(namespace: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-mongod-options.yaml"), + namespace=namespace, + ) + return resource.create() + + +@mark.e2e_replica_set_mongod_options +def test_replica_set_created(replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running) + + +@mark.e2e_replica_set_mongod_options +def test_replica_set_mongodb_options(replica_set: MongoDB): + automation_config_tester = replica_set.get_automation_config_tester() + for process in automation_config_tester.get_replica_set_processes(replica_set.name): + assert process["args2_6"]["systemLog"]["verbosity"] == 4 + assert process["args2_6"]["systemLog"]["logAppend"] + assert process["args2_6"]["operationProfiling"]["mode"] == "slowOp" + assert process["args2_6"]["net"]["port"] == 30000 + + +@mark.e2e_replica_set_mongod_options +def test_replica_set_feature_controls(replica_set: MongoDB): + fc = replica_set.get_om_tester().get_feature_controls() + assert fc["externalManagementSystem"]["name"] == "mongodb-enterprise-operator" + + assert len(fc["policies"]) == 3 + # unfortunately OM uses a HashSet for policies... + policies = sorted(fc["policies"], key=lambda policy: policy["policy"]) + assert policies[0]["policy"] == "DISABLE_SET_MONGOD_CONFIG" + assert policies[1]["policy"] == "DISABLE_SET_MONGOD_VERSION" + assert policies[2]["policy"] == "EXTERNALLY_MANAGED_LOCK" + + # OM stores the params into a set - we need to sort to compare + disabled_params = sorted(policies[0]["disabledParams"]) + assert disabled_params == [ + "net.port", + "operationProfiling.mode", + "systemLog.logAppend", + "systemLog.verbosity", + ] + + +@mark.e2e_replica_set_mongod_options +def test_replica_set_updated(replica_set: MongoDB): + replica_set["spec"]["additionalMongodConfig"]["systemLog"]["verbosity"] = 2 + replica_set["spec"]["additionalMongodConfig"]["net"]["maxIncomingConnections"] = 100 + + # update uses json merge+patch which means that deleting keys is done by setting them to None + replica_set["spec"]["additionalMongodConfig"]["operationProfiling"] = None + + replica_set.update() + replica_set.assert_abandons_phase(Phase.Running) + replica_set.assert_reaches_phase(Phase.Running) + + +@mark.e2e_replica_set_mongod_options +def test_replica_set_mongodb_options_were_updated(replica_set: MongoDB): + automation_config_tester = replica_set.get_automation_config_tester() + for process in automation_config_tester.get_replica_set_processes(replica_set.name): + assert process["args2_6"]["systemLog"]["verbosity"] == 2 + assert process["args2_6"]["systemLog"]["logAppend"] + assert process["args2_6"]["net"]["maxIncomingConnections"] == 100 + assert process["args2_6"]["net"]["port"] == 30000 + # the mode setting has been removed + assert "mode" not in process["args2_6"]["operationProfiling"] + + +@mark.e2e_replica_set_mongod_options +def test_replica_set_feature_controls_were_updated(replica_set: MongoDB): + fc = replica_set.get_om_tester().get_feature_controls() + assert fc["externalManagementSystem"]["name"] == "mongodb-enterprise-operator" + assert len(fc["policies"]) == 3 + policies = sorted(fc["policies"], key=lambda policy: policy["policy"]) + assert policies[0]["policy"] == "DISABLE_SET_MONGOD_CONFIG" + assert policies[1]["policy"] == "DISABLE_SET_MONGOD_VERSION" + assert policies[2]["policy"] == "EXTERNALLY_MANAGED_LOCK" + + disabled_params = sorted(policies[0]["disabledParams"]) + assert disabled_params == [ + "net.maxIncomingConnections", + "net.port", + "systemLog.logAppend", + "systemLog.verbosity", + ] diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_process_hostnames.py b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_process_hostnames.py new file mode 100644 index 000000000..34ebaa204 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_process_hostnames.py @@ -0,0 +1,88 @@ +# This test currently relies on MetalLB IP assignment that is configured for kind in scripts/dev/recreate_kind_cluster.sh +# Each service of type LoadBalancer will get IP starting from 172.18.255.200 +# scripts/dev/coredns_single_cluster.yaml configures that my-replica-set-0.mongodb.interconnected starts at 172.18.255.200 + +# Tests checking externalDomain (consider updating all of them when changing test logic here): +# tls/tls_replica_set_process_hostnames.py +# replicaset/replica_set_process_hostnames.py +# om_ops_manager_backup_tls_custom_ca.py + +import pytest +from pytest import fixture + +from kubetester import ( + create_or_update, + try_load, +) +from kubetester.kubetester import ( + fixture as yaml_fixture, +) +from kubetester.mongodb import MongoDB, Phase +from tests.conftest import ( + external_domain_fqdns, + default_external_domain, + update_coredns_hosts, +) + + +@fixture +def replica_set_name() -> str: + return "my-replica-set" + + +@fixture +def replica_set_members() -> int: + return 3 + + +@fixture(scope="function") +def replica_set( + namespace: str, + replica_set_name: str, + replica_set_members: int, + custom_mdb_version: str, +) -> MongoDB: + resource = MongoDB.from_yaml(yaml_fixture("replica-set.yaml"), replica_set_name, namespace) + try_load(resource) + + resource["spec"]["members"] = replica_set_members + resource["spec"]["externalAccess"] = {} + resource["spec"]["externalAccess"]["externalDomain"] = default_external_domain() + resource.set_version(custom_mdb_version) + + return resource + + +@pytest.mark.e2e_replica_set_process_hostnames +def test_update_coredns(): + hosts = [ + ("172.18.255.200", "my-replica-set-0.mongodb.interconnected"), + ("172.18.255.201", "my-replica-set-1.mongodb.interconnected"), + ("172.18.255.202", "my-replica-set-2.mongodb.interconnected"), + ("172.18.255.203", "my-replica-set-3.mongodb.interconnected"), + ] + + update_coredns_hosts(hosts) + + +@pytest.mark.e2e_replica_set_process_hostnames +def test_create_replica_set(replica_set: MongoDB): + create_or_update(replica_set) + + +@pytest.mark.e2e_replica_set_process_hostnames +def test_replica_set_in_running_state(replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running, timeout=1000) + + +@pytest.mark.e2e_replica_set_process_hostnames +def test_replica_check_automation_config(replica_set: MongoDB): + processes = replica_set.get_automation_config_tester().get_replica_set_processes(replica_set.name) + hostnames = [process["hostname"] for process in processes] + assert hostnames == external_domain_fqdns(replica_set.name, replica_set.get_members(), default_external_domain()) + + +@pytest.mark.e2e_replica_set_process_hostnames +def test_connectivity(replica_set: MongoDB): + tester = replica_set.tester() + tester.assert_connectivity() diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_pv.py b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_pv.py new file mode 100644 index 000000000..782f4d37e --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_pv.py @@ -0,0 +1,185 @@ +import pytest +import time + +from kubetester.kubetester import KubernetesTester +from kubernetes import client + + +@pytest.mark.e2e_replica_set_pv +class TestReplicaSetPersistentVolumeCreation(KubernetesTester): + """ + name: Replica Set Creation with PersistentVolumes + tags: replica-set, persistent-volumes, creation + description: | + Creates a Replica Set and allocates a PersistentVolume to it. + create: + file: replica-set-pv.yaml + wait_until: in_running_state + timeout: 300 + """ + + def test_replica_set_sts_exists(self): + sts = self.appsv1.read_namespaced_stateful_set("rs001-pv", self.namespace) + assert sts + + def test_sts_creation(self): + sts = self.appsv1.read_namespaced_stateful_set("rs001-pv", self.namespace) + + assert sts.api_version == "apps/v1" + assert sts.kind == "StatefulSet" + assert sts.status.current_replicas == 3 + assert sts.status.ready_replicas == 3 + + def test_sts_metadata(self): + sts = self.appsv1.read_namespaced_stateful_set("rs001-pv", self.namespace) + + assert sts.metadata.name == "rs001-pv" + assert sts.metadata.labels["app"] == "rs001-pv-svc" + assert sts.metadata.namespace == self.namespace + owner_ref0 = sts.metadata.owner_references[0] + assert owner_ref0.api_version == "mongodb.com/v1" + assert owner_ref0.kind == "MongoDB" + assert owner_ref0.name == "rs001-pv" + + def test_sts_replicas(self): + sts = self.appsv1.read_namespaced_stateful_set("rs001-pv", self.namespace) + assert sts.spec.replicas == 3 + + def test_sts_template(self): + sts = self.appsv1.read_namespaced_stateful_set("rs001-pv", self.namespace) + + tmpl = sts.spec.template + assert tmpl.metadata.labels["app"] == "rs001-pv-svc" + assert tmpl.metadata.labels["controller"] == "mongodb-enterprise-operator" + assert tmpl.spec.affinity.node_affinity is None + assert tmpl.spec.affinity.pod_affinity is None + assert tmpl.spec.affinity.pod_anti_affinity is not None + + def _get_pods(self, podname, qty=3): + return [podname.format(i) for i in range(qty)] + + def test_pvc_are_created_and_bound(self): + "PersistentVolumeClaims should be created with the correct attributes." + bound_pvc_names = [] + for podname in self._get_pods("rs001-pv-{}", 3): + pod = self.corev1.read_namespaced_pod(podname, self.namespace) + bound_pvc_names.append( + pod.spec.volumes[0].persistent_volume_claim.claim_name + ) + + for pvc_name in bound_pvc_names: + pvc_status = self.corev1.read_namespaced_persistent_volume_claim_status( + pvc_name, self.namespace + ) + assert pvc_status.status.phase == "Bound" + + def test_om_processes(self): + config = self.get_automation_config() + processes = config["processes"] + p0 = processes[0] + p1 = processes[1] + p2 = processes[2] + + # First Process + assert p0["name"] == "rs001-pv-0" + assert p0["processType"] == "mongod" + assert p0["version"] == "4.4.0" + assert p0["authSchemaVersion"] == 5 + assert p0["featureCompatibilityVersion"] == "4.4" + assert p0["hostname"] == "rs001-pv-0.rs001-pv-svc.{}.svc.cluster.local".format( + self.namespace + ) + assert p0["args2_6"]["net"]["port"] == 27017 + assert p0["args2_6"]["replication"]["replSetName"] == "rs001-pv" + assert p0["args2_6"]["storage"]["dbPath"] == "/data" + assert p0["args2_6"]["systemLog"]["destination"] == "file" + assert ( + p0["args2_6"]["systemLog"]["path"] + == "/var/log/mongodb-mms-automation/mongodb.log" + ) + assert p0["logRotate"]["sizeThresholdMB"] == 1000 + assert p0["logRotate"]["timeThresholdHrs"] == 24 + + # Second Process + assert p1["name"] == "rs001-pv-1" + assert p1["processType"] == "mongod" + assert p1["version"] == "4.4.0" + assert p1["authSchemaVersion"] == 5 + assert p1["featureCompatibilityVersion"] == "4.4" + assert p1["hostname"] == "rs001-pv-1.rs001-pv-svc.{}.svc.cluster.local".format( + self.namespace + ) + assert p1["args2_6"]["net"]["port"] == 27017 + assert p1["args2_6"]["replication"]["replSetName"] == "rs001-pv" + assert p1["args2_6"]["storage"]["dbPath"] == "/data" + assert p1["args2_6"]["systemLog"]["destination"] == "file" + assert ( + p1["args2_6"]["systemLog"]["path"] + == "/var/log/mongodb-mms-automation/mongodb.log" + ) + assert p1["logRotate"]["sizeThresholdMB"] == 1000 + assert p1["logRotate"]["timeThresholdHrs"] == 24 + + # Third Process + assert p2["name"] == "rs001-pv-2" + assert p2["processType"] == "mongod" + assert p2["version"] == "4.4.0" + assert p2["authSchemaVersion"] == 5 + assert p2["featureCompatibilityVersion"] == "4.4" + assert p2["hostname"] == "rs001-pv-2.rs001-pv-svc.{}.svc.cluster.local".format( + self.namespace + ) + assert p2["args2_6"]["net"]["port"] == 27017 + assert p2["args2_6"]["replication"]["replSetName"] == "rs001-pv" + assert p2["args2_6"]["storage"]["dbPath"] == "/data" + assert p2["args2_6"]["systemLog"]["destination"] == "file" + assert ( + p2["args2_6"]["systemLog"]["path"] + == "/var/log/mongodb-mms-automation/mongodb.log" + ) + assert p2["logRotate"]["sizeThresholdMB"] == 1000 + assert p2["logRotate"]["timeThresholdHrs"] == 24 + + def test_replica_set_was_configured(self): + "Should connect to one of the mongods and check the replica set was correctly configured." + hosts = [ + "rs001-pv-{}.rs001-pv-svc.{}.svc.cluster.local:27017".format( + i, self.namespace + ) + for i in range(3) + ] + + primary, secondaries = self.wait_for_rs_is_ready(hosts) + + assert primary is not None + assert len(secondaries) == 2 + + +@pytest.mark.e2e_replica_set_pv +class TestReplicaSetPersistentVolumeDelete(KubernetesTester): + """ + name: Replica Set Deletion + tags: replica-set, persistent-volumes, removal + description: | + Deletes a Replica Set. + delete: + file: replica-set-pv.yaml + wait_until: mongo_resource_deleted + timeout: 200 + """ + + def test_replica_set_sts_doesnt_exist(self): + """The StatefulSet must be removed by Kubernetes as soon as the MongoDB resource is removed. + Note, that this may lag sometimes (caching or whatever?) and it's more safe to wait a bit""" + time.sleep(15) + with pytest.raises(client.rest.ApiException): + self.appsv1.read_namespaced_stateful_set("rs001-pv", self.namespace) + + def test_service_does_not_exist(self): + "Services should not exist" + with pytest.raises(client.rest.ApiException): + self.corev1.read_namespaced_service("rs001-pv-svc", self.namespace) + + def test_pvc_are_unbound(self): + "Should check the used PVC are still there in the expected status." + pass diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_pv_multiple.py b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_pv_multiple.py new file mode 100644 index 000000000..b9dbefe8c --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_pv_multiple.py @@ -0,0 +1,120 @@ +import pytest + +from kubetester import get_default_storage_class +from kubetester.kubetester import KubernetesTester + +from operator import attrgetter + + +@pytest.mark.e2e_replica_set_pv_multiple +class TestCreateStorageClass(KubernetesTester): + """ + description: | + Creates a gp2 storage class if it does not exist already. + This is required as it seems that this storage class exists in + Kops and Openshift, but not on kind. This type of StorageClass is + based on the rancher.io/local-path provider, so it only works + on Kind. + """ + + def test_setup_gp2_storage_class(self): + KubernetesTester.make_default_gp2_storage_class() + + +@pytest.mark.e2e_replica_set_pv_multiple +class TestReplicaSetMultiplePersistentVolumeCreation(KubernetesTester): + """ + name: Replica Set Creation with Multiple PersistentVolumes + tags: replica-set, persistent-volumes, creation + description: | + Creates a Replica Set with multiple persistent volumes (one per each mount point) + create: + file: replica-set-pv-multiple.yaml + wait_until: in_running_state + timeout: 300 + """ + + RESOURCE_NAME = "rs001-pv-multiple" + custom_labels = {"label1": "val1", "label2": "val2"} + + def test_sts_creation(self): + sts = self.appsv1.read_namespaced_stateful_set( + self.RESOURCE_NAME, self.namespace + ) + + assert sts.api_version == "apps/v1" + assert sts.kind == "StatefulSet" + assert sts.status.current_replicas == 2 + assert sts.status.ready_replicas == 2 + sts_labels = sts.metadata.labels + for k in self.custom_labels: + assert k in sts_labels and sts_labels[k] == self.custom_labels[k] + + def test_pvc_are_created_and_bound(self): + """3 mount points must be mounted to 3 pvc.""" + for idx, podname in enumerate(self._get_pods(self.RESOURCE_NAME + "-{}", 2)): + pod = self.corev1.read_namespaced_pod(podname, self.namespace) + self.check_pvc_for_pod(idx, pod) + + def check_pvc_for_pod(self, idx, pod): + claims = [ + volume + for volume in pod.spec.volumes + if getattr(volume, "persistent_volume_claim") + ] + assert len(claims) == 3 + + claims.sort(key=attrgetter("name")) + + default_sc = get_default_storage_class() + KubernetesTester.check_single_pvc( + self.namespace, + claims[0], + "data", + "data-{}-{}".format(self.RESOURCE_NAME, idx), + "2Gi", + "gp2", + self.custom_labels, + ) + + # Note that PVC gets the default storage class for cluster even if it wasn't requested initially + KubernetesTester.check_single_pvc( + self.namespace, + claims[1], + "journal", + f"journal-{self.RESOURCE_NAME}-{idx}", + "1Gi", + default_sc, + self.custom_labels, + ) + KubernetesTester.check_single_pvc( + self.namespace, + claims[2], + "logs", + f"logs-{self.RESOURCE_NAME}-{idx}", + "1G", + default_sc, + self.custom_labels, + ) + + +@pytest.mark.e2e_replica_set_pv_multiple +class TestReplicaSetMultiplePersistentVolumeDelete(KubernetesTester): + """ + name: Replica Set Deletion + tags: replica-set, persistent-volumes, removal + description: | + Deletes a Replica Set. + delete: + file: replica-set-pv-multiple.yaml + wait_until: mongo_resource_deleted_no_om + timeout: 240 + """ + + def test_pvc_are_bound(self): + "Should check the used PVC are still there in the Bound status." + all_claims = self.corev1.list_namespaced_persistent_volume_claim(self.namespace) + assert len(all_claims.items) == 6 + + for claim in all_claims.items: + assert claim.status.phase == "Bound" diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_readiness_probe.py b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_readiness_probe.py new file mode 100644 index 000000000..af743c501 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_readiness_probe.py @@ -0,0 +1,84 @@ +import time + +import pytest + +from kubetester import create_or_update +from kubetester.kubetester import ( + KubernetesTester, + get_pods, + skip_if_local, + fixture as yaml_fixture, +) +from kubetester.mongodb import MongoDB, Phase + +RESOURCE_NAME = "my-replica-set-double" + + +@pytest.fixture(scope="module") +def replica_set(namespace: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-double.yaml"), RESOURCE_NAME, namespace + ) + return create_or_update(resource) + + +@pytest.fixture(scope="class") +def config_version(): + class ConfigVersion: + def __init__(self): + self.version = 0 + + return ConfigVersion() + + +@pytest.mark.e2e_replica_set_readiness_probe +class TestReplicaSetNoAgentDeadlock(KubernetesTester): + """ + name: ReplicaSet recovers when all pods are removed + description: | + Creates a 2-members replica set and then removes the pods. The pods are started sequentially (pod-0 waits for + pod-1 to get ready) but the AA in pod-1 needs pod-0 to be running to initialize replica set. The readiness probe + must be clever enough to mark the pod "ready" if the agents is waiting for the other pods. + """ + + def test_mdb_created(self, replica_set: MongoDB, config_version): + replica_set.assert_reaches_phase(Phase.Running) + config_version.version = self.get_automation_config()["version"] + + @skip_if_local() + def test_db_connectable(self, replica_set: MongoDB): + replica_set.assert_connectivity() + + def test_remove_pods_and_wait_for_recovery(self, config_version): + pods = get_pods(RESOURCE_NAME + "-{}", 2) + for podname in pods: + self.corev1.delete_namespaced_pod(podname, self.namespace) + + print("\nRemoved pod {}".format(podname)) + + # sleeping for 5 seconds to let the pods be removed + time.sleep(5) + + # waiting until the pods recover and init the replica set again + KubernetesTester.wait_until(TestReplicaSetNoAgentDeadlock.pods_are_ready, 120) + assert self.get_automation_config()["version"] == config_version.version + + @skip_if_local() + def test_db_connectable_after_recovery(self, replica_set: MongoDB): + replica_set.assert_connectivity() + + def test_replica_set_recovered(self, replica_set: MongoDB, config_version): + replica_set.assert_reaches_phase(Phase.Running) + assert self.get_automation_config()["version"] == config_version.version + + @staticmethod + def pods_are_ready(): + sts = KubernetesTester.clients("appsv1").read_namespaced_stateful_set( + "my-replica-set-double", KubernetesTester.get_namespace() + ) + + return sts.status.ready_replicas == 2 + + def test_replica_set_recovered(self, replica_set: MongoDB, config_version): + replica_set.assert_reaches_phase(Phase.Running) + assert self.get_automation_config()["version"] == config_version.version diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_recovery.py b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_recovery.py new file mode 100644 index 000000000..6eb9e2305 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_recovery.py @@ -0,0 +1,49 @@ +import pytest + +from kubetester.kubetester import KubernetesTester + + +@pytest.mark.e2e_replica_set_recovery +class TestReplicaSetBadStateCreation(KubernetesTester): + """ + name: Replica Set Bad State Creation + tags: replica-set, creation + description: | + Creates a Replica set with a bad configuration (wrong mongodb version) and ensures it enters a failed state + create: + file: replica-set-invalid.yaml + wait_until: in_error_state + timeout: 180 + """ + + def test_in_error_state(self): + mrs = KubernetesTester.get_resource() + assert mrs["status"]["phase"] == "Failed" + + # Messages about a wrong automationConfig changed from OM40 to OM42 + # This is the message emitted by the Operator + assert ( + "Failed to create/update (Ops Manager reconciliation phase)" + in mrs["status"]["message"] + ) + + +@pytest.mark.e2e_replica_set_recovery +class TestReplicaSetRecoversFromBadState(KubernetesTester): + """ + name: Replica Set Bad State Recovery + tags: replica-set, creation + description: | + Updates spec of replica set in a bad state and ensures it is updated to the running state correctly + update: + file: replica-set-invalid.yaml + patch: '[{"op":"replace","path":"/spec/version","value":"4.4.2"}]' + wait_until: in_running_state + timeout: 240 + """ + + def test_in_running_state(self): + mrs = KubernetesTester.get_resource() + status = mrs["status"] + assert status["version"] == "4.4.2" + assert "message" not in status diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_report_pending_pods.py b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_report_pending_pods.py new file mode 100644 index 000000000..d8aa11df2 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_report_pending_pods.py @@ -0,0 +1,27 @@ +from pytest import fixture, mark +from kubetester.mongodb import MongoDB, Phase +from kubetester import delete_pod, delete_pvc +from kubetester.kubetester import KubernetesTester, fixture as yaml_fixture + + +@fixture(scope="module") +def replica_set(namespace: str, custom_mdb_version: str) -> MongoDB: + resource = MongoDB.from_yaml(yaml_fixture("replica-set.yaml"), namespace=namespace) + resource.set_version(custom_mdb_version) + resource["spec"]["persistent"] = True + return resource.create() + + +@mark.e2e_replica_set_report_pending_pods +def test_replica_set_reaches_running_phase(replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running, timeout=600) + + +@mark.e2e_replica_set_report_pending_pods +def test_cr_reports_pod_pending_status(replica_set: MongoDB, namespace: str): + """delete the 0th pod and it's corresponding pvc to make sure the + pod fails to enter ready state. Another way would be to delete the nodes in the cluster + which makes the pod to be unschedulable. The former process is just easier to reproduce.""" + delete_pod(namespace, "my-replica-set-0") + delete_pvc(namespace, "data-my-replica-set-0") + replica_set.assert_reaches_phase(Phase.Pending, timeout=600) diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_schema_validation.py b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_schema_validation.py new file mode 100644 index 000000000..d1442748d --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_schema_validation.py @@ -0,0 +1,171 @@ +import time + +import pytest +from kubernetes.client import ApiException +from kubetester import MongoDB +from kubetester.kubetester import KubernetesTester +from kubetester.kubetester import fixture as yaml_fixture + + +def mdb_resource(namespace: str) -> MongoDB: + return MongoDB.from_yaml(yaml_fixture("replica-set.yaml"), namespace=namespace) + + +@pytest.mark.e2e_replica_set_schema_validation +class TestReplicaSetMembersMissing(KubernetesTester): + """ + name: Replica Set Validation (members missing) + tags: replica-set + description: | + Creates a Replica Set with required fields missing. + create: + file: replica-set.yaml + patch: '[{"op":"remove","path":"/spec/members"}]' + exception: 'Unprocessable Entity' + """ + + @pytest.mark.skip( + reason="TODO: validation for members must be done in webhook depending on the resource type" + ) + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_replica_set_schema_validation +class TestReplicaSetInvalidType(KubernetesTester): + """ + name: Replica Set Validation (invalid type) + tags: replica-set + description: | + Creates a Replica Set with an invalid field. + create: + file: replica-set.yaml + patch: '[{"op":"replace","path":"/spec/type","value":"InvalidReplicaSetType"}]' + exception: 'Unprocessable Entity' + """ + + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_replica_set_schema_validation +class TestReplicaSetInvalidLogLevel(KubernetesTester): + """ + name: Replica Set Validation (invalid type) + tags: replica-set + description: | + Creates a Replica Set with an invalid logLevel. + create: + file: replica-set.yaml + patch: '[{"op":"replace","path":"/spec/logLevel","value":"NotWARNING"}]' + exception: 'Unprocessable Entity' + """ + + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_replica_set_schema_validation +class TestReplicaSetSchemaShardedClusterMongodConfig(KubernetesTester): + """ + name: Replica Set Validation (sharded cluster additional mongod config) + create: + file: replica-set.yaml + patch: '[{"op":"add","path":"/spec/mongos","value":{"additionalMongodConfig":{"net":{"ssl":{"mode": "AllowSSL"}}}}}]' + exception: 'cannot be specified if type of MongoDB is ReplicaSet' + """ + + def test_validation_ok(self): + assert True + + +@pytest.mark.skip(reason="OpenAPIv2 does not support this validation.") +@pytest.mark.e2e_replica_set_schema_validation +class TestReplicaSetInvalidWithOpsAndCloudManager(KubernetesTester): + """Creates a Replica Set with both a cloud manager and an ops manager + configuration specified.""" + + init = { + "create": { + "file": "replica-set.yaml", + "patch": [ + { + "op": "add", + "path": "/spec/cloudManager", + "value": {"configMapRef": {"name": "something"}}, + }, + { + "op": "add", + "path": "/spec/opsManager", + "value": {"configMapRef": {"name": "something"}}, + }, + ], + "exception": "must validate one and only one schema", + }, + } + + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_replica_set_schema_validation +class TestReplicaSetInvalidWithProjectAndCloudManager(KubernetesTester): + """Creates a Replica Set with both a cloud manager and an ops manager + configuration specified.""" + + init = { + "create": { + "file": "replica-set.yaml", + "patch": [ + { + "op": "add", + "path": "/spec/cloudManager", + "value": {"configMapRef": {"name": "something"}}, + }, + ], + "exception": "must validate one and only one schema", + }, + } + + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_replica_set_schema_validation +class TestReplicaSetInvalidWithCloudAndOpsManagerAndProject(KubernetesTester): + init = { + "create": { + "file": "replica-set.yaml", + "patch": [ + { + "op": "add", + "path": "/spec/cloudManager", + "value": {"configMapRef": {"name": "something"}}, + }, + { + "op": "add", + "path": "/spec/opsManager", + "value": {"configMapRef": {"name": "something"}}, + }, + ], + "exception": "must validate one and only one schema", + }, + } + + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_replica_set_schema_validation +def test_resource_type_immutable(namespace: str): + mdb = mdb_resource(namespace).create() + # no need to wait for the resource get to Running state - we can update right away + time.sleep(5) + mdb = mdb_resource(namespace).load() + + with pytest.raises( + ApiException, + match=r"'resourceType' cannot be changed once created", + ): + mdb["spec"]["type"] = "ShardedCluster" + mdb.update() diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_statefulset_status.py b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_statefulset_status.py new file mode 100644 index 000000000..62fa15b62 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_statefulset_status.py @@ -0,0 +1,45 @@ +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.mongodb import MongoDB, Phase +from pytest import fixture, mark + + +@fixture(scope="module") +def replica_set(namespace: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-double.yaml"), + namespace=namespace, + name="replica-set-status", + ) + return resource.create() + + +@mark.e2e_replica_set_statefulset_status +def test_replica_set_reaches_pending_phase(replica_set: MongoDB): + replica_set.wait_for( + lambda s: s.get_status_resources_not_ready() is not None, + timeout=150, + should_raise=True, + ) + # the StatefulSet name is equal to replica set name + replica_set.assert_status_resource_not_ready( + replica_set.name, + msg_regexp="Not all the Pods are ready \(total: 2.*\)", + ) + replica_set.assert_reaches_phase(Phase.Pending, timeout=120) + assert replica_set.get_status_message() == "StatefulSet not ready" + + +@mark.e2e_replica_set_statefulset_status +def test_replica_set_reaches_running_phase(replica_set: MongoDB): + # The 'status.resourcesNotReady' must get cleaned soon after the replica set StatefulSet is ready - then + # the resource will stay in 'Reconciling' phase for some time waiting for the agents to reach goal state + replica_set.wait_for( + lambda s: s.get_status_resources_not_ready() is None, + timeout=150, + should_raise=True, + ) + assert replica_set.get_status_phase() == Phase.Reconciling + assert replica_set.get_status_message() is None + + replica_set.assert_reaches_phase(Phase.Running, timeout=100) + assert replica_set.get_status_resources_not_ready() is None diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_update_delete_parallel.py b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_update_delete_parallel.py new file mode 100644 index 000000000..94804a3ad --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_update_delete_parallel.py @@ -0,0 +1,47 @@ +""" +This is a test which makes sure that update and delete calls issued together don't mess up +OM group state. Internal locks are used to ensure OM requests for update/delete operations +don't intersect. Note that K8s objects are removed right after the delete call is made(no +serialization happens) so update operation doesn't succeed as internal locks don't affect +K8s CR and dependent resources removal. +""" +from kubetester.kubetester import fixture as yaml_fixture, run_periodically +from kubetester.mongodb import MongoDB, Phase +from pytest import fixture, mark +from time import sleep + + +@fixture(scope="module") +def replica_set(namespace: str, custom_mdb_version: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-single.yaml"), "my-replica-set", namespace + ) + resource.set_version(custom_mdb_version) + resource.create() + + return resource + + +@mark.e2e_replica_set_update_delete_parallel +def test_reaches_running_phase(replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running) + replica_set.get_automation_config_tester().assert_replica_sets_size(1) + + +@mark.e2e_replica_set_update_delete_parallel +def test_update_delete_in_parallel(replica_set: MongoDB): + replica_set["spec"]["members"] = 2 + replica_set.update() + sleep(5) + replica_set.delete() + + om_tester = replica_set.get_om_tester() + + def om_is_clean(): + try: + om_tester.assert_hosts_empty() + return True + except AssertionError: + return False + + run_periodically(om_is_clean, timeout=180) diff --git a/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_upgrade_downgrade.py b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_upgrade_downgrade.py new file mode 100644 index 000000000..64166bf62 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/replicaset/replica_set_upgrade_downgrade.py @@ -0,0 +1,75 @@ +from pytest import fixture, mark +from kubetester.kubetester import KubernetesTester + +from kubetester.mongotester import ReplicaSetTester + + +TEST_DATA = {"foo": "bar"} +TEST_DB = "testdb" +TEST_COLLECTION = "testcollection" + + +@fixture +def mongod_tester(): + return ReplicaSetTester("my-replica-set-downgrade", 3) + + +@fixture +def mdb_test_collection(mongod_tester): + collection = mongod_tester.client[TEST_DB] + return collection[TEST_COLLECTION] + + +@mark.e2e_replica_set_upgrade_downgrade +class TestReplicaSetUpgradeDowngradeCreate(KubernetesTester): + """ + name: ReplicaSet upgrade downgrade (create) + description: | + Creates a replica set, then upgrades it with compatibility version set and then downgrades back + create: + file: replica-set-downgrade.yaml + wait_until: in_running_state + timeout: 300 + """ + + def test_db_connectable(self, mongod_tester): + mongod_tester.assert_version("4.4.2") + + def test_insert_test_data(self, mdb_test_collection): + mdb_test_collection.insert_one(TEST_DATA) + + +@mark.e2e_replica_set_upgrade_downgrade +class TestReplicaSetUpgradeDowngradeUpdate(KubernetesTester): + """ + name: ReplicaSet upgrade downgrade (update) + description: | + Updates a ReplicaSet to bigger version, leaving feature compatibility version as it was + update: + file: replica-set-downgrade.yaml + patch: '[{"op":"replace","path":"/spec/version", "value": "4.4.0"}, {"op":"add","path":"/spec/featureCompatibilityVersion", "value": "4.4"}]' + wait_until: in_running_state + timeout: 300 + """ + + def test_db_connectable(self, mongod_tester): + mongod_tester.assert_version("4.4.0") + + +@mark.e2e_replica_set_upgrade_downgrade +class TestReplicaSetUpgradeDowngradeRevert(KubernetesTester): + """ + name: ReplicaSet upgrade downgrade (downgrade) + description: | + Updates a ReplicaSet to the same version it was created initially + update: + file: replica-set-downgrade.yaml + wait_until: in_running_state + timeout: 300 + """ + + def test_db_connectable(self, mongod_tester): + mongod_tester.assert_version("4.4.2") + + def test_data_exists(self, mdb_test_collection): + assert mdb_test_collection.find().count() == 1 diff --git a/docker/mongodb-enterprise-tests/tests/shardedcluster/__init__.py b/docker/mongodb-enterprise-tests/tests/shardedcluster/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/docker/mongodb-enterprise-tests/tests/shardedcluster/conftest.py b/docker/mongodb-enterprise-tests/tests/shardedcluster/conftest.py new file mode 100644 index 000000000..cb0664057 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/shardedcluster/conftest.py @@ -0,0 +1,4 @@ +def pytest_runtest_setup(item): + """This allows to automatically install the default Operator before running any test""" + if "default_operator" not in item.fixturenames: + item.fixturenames.insert(0, "default_operator") diff --git a/docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster-custom-podspec.yaml b/docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster-custom-podspec.yaml new file mode 100644 index 000000000..3a6f75d7f --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster-custom-podspec.yaml @@ -0,0 +1,78 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: my-sharded-cluster-custom-podspec +spec: + shardCount: 3 + mongodsPerShardCount: 1 + mongosCount: 1 + configServerCount: 1 + type: ShardedCluster + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + persistent: false + configSrvPodSpec: + podTemplate: + spec: + terminationGracePeriodSeconds: 50 + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + topologyKey: "config" + weight: 30 + mongosPodSpec: + podTemplate: + spec: + terminationGracePeriodSeconds: 20 + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + topologyKey: "mongos" + weight: 40 + shardPodSpec: + cpu: "2" # ignored as podTemplate takes precedence if provided + podTemplate: + spec: + containers: + - name: sharded-cluster-sidecar + image: busybox + command: ["sleep"] + args: [ "infinity" ] + resources: + limits: + cpu: "1" + requests: + cpu: 500m + terminationGracePeriodSeconds: 30 + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + topologyKey: "shard" + weight: 50 + shardSpecificPodSpec: + - podTemplate: + spec: + containers: + - name: sharded-cluster-sidecar-override + image: busybox + command: ["sleep"] + args: [ "infinity" ] + resources: + limits: + cpu: "1" + requests: + cpu: 500m + terminationGracePeriodSeconds: 60 + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + topologyKey: "shardoverride" + weight: 100 diff --git a/docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster-downgrade.yaml b/docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster-downgrade.yaml new file mode 100644 index 000000000..ec82bf492 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster-downgrade.yaml @@ -0,0 +1,17 @@ +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: sh001-downgrade +spec: + type: ShardedCluster + shardCount: 2 + mongodsPerShardCount: 1 + mongosCount: 1 + configServerCount: 1 + version: 4.4.2 + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + persistent: false diff --git a/docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster-mongod-options.yaml b/docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster-mongod-options.yaml new file mode 100644 index 000000000..476a7b993 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster-mongod-options.yaml @@ -0,0 +1,37 @@ +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: my-sharded-cluster-options +spec: + members: 3 + version: 4.4.0-ent + type: ShardedCluster + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + persistent: false + shardCount: 2 + mongodsPerShardCount: 3 + mongosCount: 2 + configServerCount: 1 + mongos: + additionalMongodConfig: + net: + port: 30003 + systemLog: + logAppend: true + verbosity: 4 + configSrv: + additionalMongodConfig: + operationProfiling: + mode: slowOp + net: + port: 30002 + shard: + additionalMongodConfig: + storage: + journal: + commitIntervalMs: 50 + net: + port: 30001 diff --git a/docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster-pv.yaml b/docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster-pv.yaml new file mode 100644 index 000000000..58991e9ab --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster-pv.yaml @@ -0,0 +1,30 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: sh001-pv + labels: + label1: val1 + label2: val2 +spec: + shardCount: 1 + mongodsPerShardCount: 3 + mongosCount: 2 + configServerCount: 3 + version: 4.4.0 + type: ShardedCluster + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + persistent: true + shardPodSpec: + persistence: + single: + storage: 1G + + configSrvPodSpec: + persistence: + single: + storage: 1G diff --git a/docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster-scale-down-shards.yaml b/docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster-scale-down-shards.yaml new file mode 100644 index 000000000..6ac4ae7ab --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster-scale-down-shards.yaml @@ -0,0 +1,16 @@ +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: sh001-scale-down-shards +spec: + type: ShardedCluster + shardCount: 2 + mongodsPerShardCount: 3 + mongosCount: 2 + configServerCount: 2 + version: 4.4.0 + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + persistent: false diff --git a/docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster-scale-shards.yaml b/docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster-scale-shards.yaml new file mode 100644 index 000000000..747a30e2c --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster-scale-shards.yaml @@ -0,0 +1,16 @@ +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: sh001-scale-down-shards +spec: + type: ShardedCluster + shardCount: 2 + mongodsPerShardCount: 1 + mongosCount: 1 + configServerCount: 1 + version: 4.4.0 + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + persistent: false diff --git a/docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster-single.yaml b/docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster-single.yaml new file mode 100644 index 000000000..db92e7d9e --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster-single.yaml @@ -0,0 +1,20 @@ +# This is the simplest sharded cluster possible (1 member for everything) - use it for the tests not focused on sharded +# cluster behaviour (e.g. listening to secrets changes), this will result in minimal running time and lowest resource +# consumption +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: sh001-single +spec: + type: ShardedCluster + shardCount: 1 + mongodsPerShardCount: 1 + mongosCount: 1 + configServerCount: 1 + version: 4.0.15 + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + persistent: false diff --git a/docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster.yaml b/docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster.yaml new file mode 100644 index 000000000..accb52816 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/shardedcluster/fixtures/sharded-cluster.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: sh001-base +spec: + shardCount: 1 + type: ShardedCluster + mongodsPerShardCount: 3 + mongosCount: 2 + configServerCount: 3 + version: 4.0.15 + cloudManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: WARN + persistent: true diff --git a/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster.py b/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster.py new file mode 100644 index 000000000..de8613310 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster.py @@ -0,0 +1,120 @@ +import pytest + +from kubetester.kubetester import KubernetesTester +from kubetester.mongotester import ShardedClusterTester +from kubernetes import client + + +@pytest.mark.e2e_sharded_cluster +class TestShardedClusterCreation(KubernetesTester): + """ + name: Sharded Cluster Base Creation + description: | + Creates a simple Sharded Cluster with 1 shard, 2 mongos, + 1 replica set as config server and NO persistent volumes. + create: + file: sharded-cluster.yaml + wait_until: in_running_state + timeout: 360 + """ + + def test_sharded_cluster_sts(self): + sts0 = self.appsv1.read_namespaced_stateful_set("sh001-base-0", self.namespace) + assert sts0 + + def test_config_sts(self): + config = self.appsv1.read_namespaced_stateful_set("sh001-base-config", self.namespace) + assert config + + def test_mongos_sts(self): + mongos = self.appsv1.read_namespaced_stateful_set("sh001-base-mongos", self.namespace) + assert mongos + + def test_mongod_sharded_cluster_service(self): + svc0 = self.corev1.read_namespaced_service("sh001-base-sh", self.namespace) + assert svc0 + + def test_shard0_was_configured(self): + ShardedClusterTester("sh001-base", 1).assert_connectivity() + + def test_shard0_was_configured_with_srv(self): + ShardedClusterTester("sh001-base", 1, ssl=False, srv=True).assert_connectivity() + + def test_monitoring_versions(self): + """Verifies that monitoring agent is configured for each process in the deployment""" + config = self.get_automation_config() + mv = config["monitoringVersions"] + assert len(mv) == 8 + + for process in config["processes"]: + assert any(agent for agent in mv if agent["hostname"] == process["hostname"]) + + def test_backup_versions(self): + """Verifies that backup agent is configured for each process in the deployment""" + config = self.get_automation_config() + mv = config["backupVersions"] + assert len(mv) == 8 + + for process in config["processes"]: + assert any(agent for agent in mv if agent["hostname"] == process["hostname"]) + + +@pytest.mark.e2e_sharded_cluster +class TestShardedClusterUpdate(KubernetesTester): + """ + name: Sharded Cluster Base Creation + description: | + Scales a Sharded Cluster from 1 to 2 Shards + update: + file: sharded-cluster.yaml + patch: '[{"op":"replace","path":"/spec/shardCount","value":2}]' + wait_until: in_running_state + timeout: 360 + """ + + def test_shard1_was_configured(self): + hosts = ["sh001-base-1-{}.sh001-base-sh.{}.svc.cluster.local:27017".format(i, self.namespace) for i in range(3)] + + primary, secondaries = self.wait_for_rs_is_ready(hosts) + assert primary is not None + assert len(secondaries) == 2 + + def test_monitoring_versions(self): + """Verifies that monitoring agent is configured for each process in the deployment""" + config = self.get_automation_config() + mv = config["monitoringVersions"] + assert len(mv) == 11 + + for process in config["processes"]: + assert any(agent for agent in mv if agent["hostname"] == process["hostname"]) + + def test_backup_versions(self): + """Verifies that backup agent is configured for each process in the deployment""" + config = self.get_automation_config() + mv = config["backupVersions"] + assert len(mv) == 11 + + for process in config["processes"]: + assert any(agent for agent in mv if agent["hostname"] == process["hostname"]) + + +@pytest.mark.e2e_sharded_cluster +class TestShardedClusterDeletion(KubernetesTester): + """ + name: Sharded Cluster Base Deletion + description: | + Removes a Sharded Cluster + delete: + file: sharded-cluster.yaml + wait_until: mongo_resource_deleted + timeout: 240 + """ + + def test_sharded_cluster_doesnt_exist(self): + # There should be no statefulsets in this namespace + sts = self.appsv1.list_namespaced_stateful_set(self.namespace) + assert len(sts.items) == 0 + + def test_service_does_not_exist(self): + with pytest.raises(client.rest.ApiException): + self.corev1.read_namespaced_service("sh001-base-sh", self.namespace) diff --git a/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_agent_flags.py b/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_agent_flags.py new file mode 100644 index 000000000..b5a4d273f --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_agent_flags.py @@ -0,0 +1,83 @@ +from pytest import mark, fixture + +from kubetester import find_fixture + +from kubetester.mongodb import MongoDB, Phase + +from kubetester.kubetester import KubernetesTester + + +@fixture(scope="module") +def sharded_cluster(namespace: str) -> MongoDB: + resource = MongoDB.from_yaml( + find_fixture("sharded-cluster.yaml"), namespace=namespace + ) + + resource["spec"]["configSrv"] = { + "agent": { + "startupOptions": { + "logFile": "/var/log/mongodb-mms-automation/customLogFileSrv" + } + } + } + resource["spec"]["mongos"] = { + "agent": { + "startupOptions": { + "logFile": "/var/log/mongodb-mms-automation/customLogFileMongos" + } + } + } + resource["spec"]["shard"] = { + "agent": { + "startupOptions": { + "logFile": "/var/log/mongodb-mms-automation/customLogFileShard" + } + } + } + + return resource.create() + + +@mark.e2e_sharded_cluster_agent_flags +def test_sharded_cluster(sharded_cluster: MongoDB): + sharded_cluster.assert_reaches_phase(Phase.Running, timeout=400) + + +@mark.e2e_sharded_cluster_agent_flags +def test_sharded_cluster_has_agent_flags(sharded_cluster: MongoDB, namespace: str): + for i in range(3): + cmd = [ + "/bin/sh", + "-c", + "ls /var/log/mongodb-mms-automation/customLogFileShard* | wc -l", + ] + result = KubernetesTester.run_command_in_pod_container( + f"sh001-base-0-{i}", + namespace, + cmd, + ) + assert result != "0" + for i in range(3): + cmd = [ + "/bin/sh", + "-c", + "ls /var/log/mongodb-mms-automation/customLogFileSrv* | wc -l", + ] + result = KubernetesTester.run_command_in_pod_container( + f"sh001-base-config-{i}", + namespace, + cmd, + ) + assert result != "0" + for i in range(2): + cmd = [ + "/bin/sh", + "-c", + "ls /var/log/mongodb-mms-automation/customLogFileMongos* | wc -l", + ] + result = KubernetesTester.run_command_in_pod_container( + f"sh001-base-mongos-{i}", + namespace, + cmd, + ) + assert result != "0" diff --git a/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_custom_podspec.py b/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_custom_podspec.py new file mode 100644 index 000000000..c571cfb8d --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_custom_podspec.py @@ -0,0 +1,91 @@ +from kubetester.custom_podspec import assert_stateful_set_podspec +from kubetester.kubetester import fixture as yaml_fixture, KubernetesTester +from kubetester.mongodb import MongoDB, Phase +from pytest import fixture, mark + +SHARD_WEIGHT = 50 +MONGOS_WEIGHT = 40 +CONFIG_WEIGHT = 30 +SHARD0_WEIGHT = 100 + +SHARD_GRACE_PERIOD = 30 +MONGOS_GRACE_PERIOD = 20 +CONFIG_GRACE_PERIOD = 50 +SHARD0_GRACE_PERIOD = 60 + +SHARD_TOPOLOGY_KEY = "shard" +MONGOS_TOPOLOGY_KEY = "mongos" +CONFIG_TOPOLOGY_KEY = "config" +SHARD0_TOPLOGY_KEY = "shardoverride" + + +@fixture(scope="module") +def sharded_cluster(namespace: str, custom_mdb_version: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("sharded-cluster-custom-podspec.yaml"), namespace=namespace + ) + resource.set_version(custom_mdb_version) + return resource.create() + + +@mark.e2e_sharded_cluster_custom_podspec +def test_replica_set_reaches_running_phase(sharded_cluster): + sharded_cluster.assert_reaches_phase(Phase.Running, timeout=600) + + +@mark.e2e_sharded_cluster_custom_podspec +def test_stateful_sets_spec_updated(sharded_cluster, namespace): + appsv1 = KubernetesTester.clients("appsv1") + config_sts = appsv1.read_namespaced_stateful_set( + f"{sharded_cluster.name}-config", namespace + ) + mongos_sts = appsv1.read_namespaced_stateful_set( + f"{sharded_cluster.name}-mongos", namespace + ) + shard0_sts = appsv1.read_namespaced_stateful_set( + f"{sharded_cluster.name}-0", namespace + ) + shard_sts = appsv1.read_namespaced_stateful_set( + f"{sharded_cluster.name}-1", namespace + ) + + assert_stateful_set_podspec( + config_sts.spec.template.spec, + weight=CONFIG_WEIGHT, + grace_period_seconds=CONFIG_GRACE_PERIOD, + topology_key=CONFIG_TOPOLOGY_KEY, + ) + assert_stateful_set_podspec( + mongos_sts.spec.template.spec, + weight=MONGOS_WEIGHT, + grace_period_seconds=MONGOS_GRACE_PERIOD, + topology_key=MONGOS_TOPOLOGY_KEY, + ) + assert_stateful_set_podspec( + shard_sts.spec.template.spec, + weight=SHARD_WEIGHT, + grace_period_seconds=SHARD_GRACE_PERIOD, + topology_key=SHARD_TOPOLOGY_KEY, + ) + + assert_stateful_set_podspec( + shard0_sts.spec.template.spec, + weight=SHARD0_WEIGHT, + grace_period_seconds=SHARD0_GRACE_PERIOD, + topology_key=SHARD0_TOPLOGY_KEY, + ) + containers = shard_sts.spec.template.spec.containers + + assert len(containers) == 2 + assert containers[0].name == "mongodb-enterprise-database" + assert containers[1].name == "sharded-cluster-sidecar" + + containers = shard0_sts.spec.template.spec.containers + assert len(containers) == 2 + assert containers[0].name == "mongodb-enterprise-database" + assert containers[1].name == "sharded-cluster-sidecar-override" + + resources = containers[1].resources + + assert resources.limits["cpu"] == "1" + assert resources.requests["cpu"] == "500m" diff --git a/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_mongod_options.py b/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_mongod_options.py new file mode 100644 index 000000000..6668e0f55 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_mongod_options.py @@ -0,0 +1,149 @@ +from pytest import fixture, mark + +from kubetester import create_or_update +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.mongodb import MongoDB, Phase +from kubernetes import client + + +@fixture(scope="module") +def sharded_cluster(namespace: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("sharded-cluster-mongod-options.yaml"), + namespace=namespace, + ) + create_or_update(resource) + return resource + + +@mark.e2e_sharded_cluster_mongod_options +def test_sharded_cluster_created(sharded_cluster: MongoDB): + sharded_cluster.assert_reaches_phase(Phase.Running, timeout=600) + + +@mark.e2e_sharded_cluster_mongod_options +def test_sharded_cluster_mongodb_options_mongos(sharded_cluster: MongoDB): + automation_config_tester = sharded_cluster.get_automation_config_tester() + for process in automation_config_tester.get_mongos_processes(): + assert process["args2_6"]["systemLog"]["verbosity"] == 4 + assert process["args2_6"]["systemLog"]["logAppend"] + assert "operationProfiling" not in process["args2_6"] + assert "storage" not in process["args2_6"] + assert process["args2_6"]["net"]["port"] == 30003 + + +@mark.e2e_sharded_cluster_mongod_options +def test_sharded_cluster_mongodb_options_config_srv(sharded_cluster: MongoDB): + automation_config_tester = sharded_cluster.get_automation_config_tester() + for process in automation_config_tester.get_replica_set_processes( + sharded_cluster.config_srv_statefulset_name() + ): + assert process["args2_6"]["operationProfiling"]["mode"] == "slowOp" + assert "verbosity" not in process["args2_6"]["systemLog"] + assert "logAppend" not in process["args2_6"]["systemLog"] + assert "journal" not in process["args2_6"]["storage"] + assert process["args2_6"]["net"]["port"] == 30002 + + +@mark.e2e_sharded_cluster_mongod_options +def test_sharded_cluster_mongodb_options_shards(sharded_cluster: MongoDB): + automation_config_tester = sharded_cluster.get_automation_config_tester() + for shard_name in sharded_cluster.shards_statefulsets_names(): + for process in automation_config_tester.get_replica_set_processes(shard_name): + assert process["args2_6"]["storage"]["journal"]["commitIntervalMs"] == 50 + assert "verbosity" not in process["args2_6"]["systemLog"] + assert "logAppend" not in process["args2_6"]["systemLog"] + assert "operationProfiling" not in process["args2_6"] + assert process["args2_6"]["net"]["port"] == 30001 + + +@mark.e2e_sharded_cluster_mongod_options +def test_sharded_cluster_feature_controls(sharded_cluster: MongoDB): + fc = sharded_cluster.get_om_tester().get_feature_controls() + assert fc["externalManagementSystem"]["name"] == "mongodb-enterprise-operator" + + assert len(fc["policies"]) == 3 + # unfortunately OM uses a HashSet for policies... + policies = sorted(fc["policies"], key=lambda policy: policy["policy"]) + assert policies[0]["policy"] == "DISABLE_SET_MONGOD_CONFIG" + assert policies[1]["policy"] == "DISABLE_SET_MONGOD_VERSION" + assert policies[2]["policy"] == "EXTERNALLY_MANAGED_LOCK" + # OM stores the params into a set - we need to sort to compare + disabled_params = sorted(policies[0]["disabledParams"]) + assert disabled_params == [ + "net.port", + "operationProfiling.mode", + "storage.journal.commitIntervalMs", + "systemLog.logAppend", + "systemLog.verbosity", + ] + + +@mark.e2e_sharded_cluster_mongod_options +def test_remove_fields(sharded_cluster: MongoDB): + sharded_cluster.load() + + # delete a field from each component + del sharded_cluster["spec"]["mongos"]["additionalMongodConfig"]["systemLog"][ + "verbosity" + ] + del sharded_cluster["spec"]["shard"]["additionalMongodConfig"]["storage"][ + "journal" + ]["commitIntervalMs"] + del sharded_cluster["spec"]["configSrv"]["additionalMongodConfig"][ + "operationProfiling" + ]["mode"] + + client.CustomObjectsApi().replace_namespaced_custom_object( + sharded_cluster.group, + sharded_cluster.version, + sharded_cluster.namespace, + sharded_cluster.plural, + sharded_cluster.name, + sharded_cluster.backing_obj, + ) + + sharded_cluster.assert_abandons_phase(Phase.Running) + sharded_cluster.assert_reaches_phase(Phase.Running) + + +@mark.e2e_sharded_cluster_mongod_options +def test_fields_are_successfully_removed_from_mongos(sharded_cluster: MongoDB): + automation_config_tester = sharded_cluster.get_automation_config_tester() + for process in automation_config_tester.get_mongos_processes(): + assert "verbosity" not in process["args2_6"]["systemLog"] + + # other fields are still there + assert process["args2_6"]["systemLog"]["logAppend"] + assert "operationProfiling" not in process["args2_6"] + assert "storage" not in process["args2_6"] + assert process["args2_6"]["net"]["port"] == 30003 + + +@mark.e2e_sharded_cluster_mongod_options +def test_fields_are_successfully_removed_from_config_srv(sharded_cluster: MongoDB): + automation_config_tester = sharded_cluster.get_automation_config_tester() + for process in automation_config_tester.get_replica_set_processes( + sharded_cluster.config_srv_statefulset_name() + ): + assert "mode" not in process["args2_6"]["operationProfiling"] + + # other fields are still there + assert "verbosity" not in process["args2_6"]["systemLog"] + assert "logAppend" not in process["args2_6"]["systemLog"] + assert "journal" not in process["args2_6"]["storage"] + assert process["args2_6"]["net"]["port"] == 30002 + + +@mark.e2e_sharded_cluster_mongod_options +def test_fields_are_successfully_removed_from_shards(sharded_cluster: MongoDB): + automation_config_tester = sharded_cluster.get_automation_config_tester() + for shard_name in sharded_cluster.shards_statefulsets_names(): + for process in automation_config_tester.get_replica_set_processes(shard_name): + assert "commitIntervalMs" not in process["args2_6"]["storage"]["journal"] + + # other fields are still there + assert "verbosity" not in process["args2_6"]["systemLog"] + assert "logAppend" not in process["args2_6"]["systemLog"] + assert "operationProfiling" not in process["args2_6"] + assert process["args2_6"]["net"]["port"] == 30001 diff --git a/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_pv.py b/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_pv.py new file mode 100644 index 000000000..1fe553a3d --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_pv.py @@ -0,0 +1,115 @@ +import pytest +import time + +from kubetester.kubetester import KubernetesTester +from kubernetes import client +from kubetester.mongotester import ShardedClusterTester + + +@pytest.mark.e2e_sharded_cluster_pv +class TestShardedClusterCreation(KubernetesTester): + """ + name: Sharded Cluster Creation with PV + description: | + Creates a simple Sharded Cluster with 1 shard, 2 mongos, + 1 replica set as config server and basic PV + create: + file: sharded-cluster-pv.yaml + wait_until: in_running_state + timeout: 360 + """ + + custom_labels = {"label1": "val1", "label2": "val2"} + + def check_sts_labels(self, sts): + sts_labels = sts.metadata.labels + for k in self.custom_labels: + assert k in sts_labels and sts_labels[k] == self.custom_labels[k] + + def check_pvc_labels(self, pvc): + pvc_labels = pvc.metadata.labels + for k in self.custom_labels: + assert k in pvc_labels and pvc_labels[k] == self.custom_labels[k] + + def test_sharded_cluster_sts(self): + sts0 = self.appsv1.read_namespaced_stateful_set("sh001-pv-0", self.namespace) + assert sts0 + self.check_sts_labels(sts0) + + def test_config_sts(self): + config = self.appsv1.read_namespaced_stateful_set( + "sh001-pv-config", self.namespace + ) + assert config + self.check_sts_labels(config) + + def test_mongos_sts(self): + mongos = self.appsv1.read_namespaced_stateful_set( + "sh001-pv-mongos", self.namespace + ) + assert mongos + self.check_sts_labels(mongos) + + def test_mongod_sharded_cluster_service(self): + svc0 = self.corev1.read_namespaced_service("sh001-pv-sh", self.namespace) + assert svc0 + + def test_shard0_was_configured(self): + hosts = [ + "sh001-pv-0-{}.sh001-pv-sh.{}.svc.cluster.local:27017".format( + i, self.namespace + ) + for i in range(3) + ] + + primary, secondaries = self.wait_for_rs_is_ready(hosts) + + assert primary is not None + assert len(secondaries) == 2 + + def test_pvc_are_bound(self): + pvc_shards = ["data-sh001-pv-0-{}".format(x) for x in range(3)] + for pvc_name in pvc_shards: + pvc = self.corev1.read_namespaced_persistent_volume_claim( + pvc_name, self.namespace + ) + assert pvc.status.phase == "Bound" + assert pvc.spec.resources.requests["storage"] == "1G" + self.check_pvc_labels(pvc) + + pvc_config = ["data-sh001-pv-config-{}".format(x) for x in range(3)] + for pvc_name in pvc_config: + pvc = self.corev1.read_namespaced_persistent_volume_claim( + pvc_name, self.namespace + ) + assert pvc.status.phase == "Bound" + assert pvc.spec.resources.requests["storage"] == "1G" + self.check_pvc_labels(pvc) + + def test_mongos_are_reachable(self): + ShardedClusterTester("sh001-pv", 2) + + +@pytest.mark.e2e_sharded_cluster_pv +class TestShardedClusterDeletion(KubernetesTester): + """ + name: Sharded Cluster Deletion with PV + description: | + Removes a Sharded Cluster with PV + delete: + file: sharded-cluster-pv.yaml + wait_until: mongo_resource_deleted_no_om + timeout: 300 + + """ + + def test_sharded_cluster_doesnt_exist(self): + """The StatefulSet must be removed by Kubernetes as soon as the MongoDB resource is removed. + Note, that this may lag sometimes (caching or whatever?) and it's more safe to wait a bit""" + time.sleep(15) + with pytest.raises(client.rest.ApiException): + self.appsv1.read_namespaced_stateful_set("sh001-pv-0", self.namespace) + + def test_service_does_not_exist(self): + with pytest.raises(client.rest.ApiException): + self.corev1.read_namespaced_service("sh001-pv-sh", self.namespace) diff --git a/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_recovery.py b/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_recovery.py new file mode 100644 index 000000000..0b463e37a --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_recovery.py @@ -0,0 +1,40 @@ +import pytest + +import yaml +from kubernetes.client import V1Secret + +from kubetester.kubetester import KubernetesTester, fixture + + +@pytest.mark.e2e_sharded_cluster_recovery +class TestShardedClusterRecoversBadOmConfiguration(KubernetesTester): + """ + name: Sharded cluster broken OM connection + description: | + Creates a sharded cluster with a bad OM connection (public key is broken) and ensures it enters a failed state | + Then the secret is fixed and the standalone is expected to reach good state eventually + """ + + @classmethod + def setup_env(cls): + secret = V1Secret(string_data={"publicApiKey": "wrongKey"}) + cls.clients("corev1").patch_namespaced_secret( + "my-credentials", cls.get_namespace(), secret + ) + + resource = yaml.safe_load(open(fixture("sharded-cluster-single.yaml"))) + + cls.create_custom_resource_from_object(cls.get_namespace(), resource) + + KubernetesTester.wait_until("in_error_state", 20) + + mrs = KubernetesTester.get_resource() + assert "You are not authorized for this resource" in mrs["status"]["message"] + + def test_recovery(self): + secret = V1Secret(string_data={"publicApiKey": self.get_om_api_key()}) + self.clients("corev1").patch_namespaced_secret( + "my-credentials", self.get_namespace(), secret + ) + + KubernetesTester.wait_until(KubernetesTester.in_running_state_failures_possible) diff --git a/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_scale_down_shards.py b/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_scale_down_shards.py new file mode 100644 index 000000000..435a6c554 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_scale_down_shards.py @@ -0,0 +1,54 @@ +import pytest +from kubernetes import client +from kubetester.mongodb import MongoDB, Phase +from kubetester.kubetester import fixture as _fixture +from pytest import fixture + + +@fixture(scope="module") +def sharded_cluster(namespace: str) -> MongoDB: + resource = MongoDB.from_yaml( + _fixture("sharded-cluster-scale-down-shards.yaml"), namespace=namespace + ) + return resource.create() + + +@pytest.mark.e2e_sharded_cluster_scale_down_shards +def test_db_connectable(sharded_cluster: MongoDB): + sharded_cluster.assert_reaches_phase(Phase.Running, timeout=300) + + mongod_tester = sharded_cluster.tester() + mongod_tester.shard_collection("sh001-scale-down-shards-{}", 2, "type") + mongod_tester.upload_random_data(50_000) + mongod_tester.prepare_for_shard_removal("sh001-scale-down-shards-{}", 2) + mongod_tester.assert_number_of_shards(2) + # todo would be great to verify that chunks are distributed over shards, but I didn't manage to get the same + # results as from CMD sh.status()(alisovenko) + # self.client.config.command('printShardingStatus') --> doesn't work + + +@pytest.mark.e2e_sharded_cluster_scale_down_shards +def test_db_data_the_same_count(sharded_cluster: MongoDB): + """ + Updates the sharded cluster, scaling down its shards count to 1. Makes sure no data is lost. + """ + sharded_cluster.load() + sharded_cluster["spec"]["shardCount"] = 1 + sharded_cluster["spec"]["mongodsPerShardCount"] = 1 + sharded_cluster["spec"]["mongosCount"] = 1 + sharded_cluster["spec"]["configServerCount"] = 1 + sharded_cluster.update() + + sharded_cluster.assert_reaches_phase(Phase.Running, timeout=1200) + + mongod_tester = sharded_cluster.tester() + mongod_tester.assert_number_of_shards(1) + mongod_tester.assert_data_size(50_000) + + +@pytest.mark.e2e_sharded_cluster_scale_down_shards +def test_statefulset_for_shard_removed(namespace: str): + with pytest.raises(client.rest.ApiException): + client.AppsV1Api().read_namespaced_stateful_set( + "sh001-scale-down-shards-1", namespace + ) diff --git a/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_scale_shards.py b/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_scale_shards.py new file mode 100644 index 000000000..481cc4ad8 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_scale_shards.py @@ -0,0 +1,84 @@ +import pytest +from kubetester.kubetester import KubernetesTester +from kubetester.mongotester import ShardedClusterTester +from kubernetes import client + + +@pytest.mark.e2e_sharded_cluster_scale_shards +class TestShardedClusterScaleShardsCreate(KubernetesTester): + """ + name: ShardedCluster scale of shards (create) + description: | + Creates a sharded cluster with 2 shards + create: + file: sharded-cluster-scale-shards.yaml + wait_until: in_running_state + timeout: 240 + """ + + def test_db_connectable(self): + mongod_tester = ShardedClusterTester("sh001-scale-down-shards", 1) + mongod_tester.shard_collection("sh001-scale-down-shards-{}", 2, "type") + mongod_tester.upload_random_data(50_000) + mongod_tester.prepare_for_shard_removal("sh001-scale-down-shards-{}", 2) + mongod_tester.assert_number_of_shards(2) + # todo would be great to verify that chunks are distributed over shards, but I didn't manage to get the same + # results as from CMD sh.status()(alisovenko) + # self.client.config.command('printShardingStatus') --> doesn't work + + +@pytest.mark.e2e_sharded_cluster_scale_shards +class TestShardedClusterScaleDownShards(KubernetesTester): + """ + name: ShardedCluster scale down of shards (update) + description: | + Updates the sharded cluster, scaling down its shards count to 1. Makes sure no data is lost. + (alisovenko) Implementation notes: I tried to get long rebalancing to make sure it's covered with multiple reconciliations, + but in fact rebalancing is almost immediate (insertion is way longer) so the single reconciliation manages to get + agents + update: + file: sharded-cluster-scale-shards.yaml + patch: '[{"op":"replace","path":"/spec/shardCount", "value": 1}]' + wait_until: in_running_state + timeout: 360 + """ + + def test_db_data_the_same_count(self): + mongod_tester = ShardedClusterTester("sh001-scale-down-shards", 1) + + mongod_tester.assert_number_of_shards(1) + mongod_tester.assert_data_size(50_000) + + def test_statefulset_for_shard_removed(self): + with pytest.raises(client.rest.ApiException): + self.appsv1.read_namespaced_stateful_set( + "sh001-scale-down-shards-1", self.namespace + ) + + +@pytest.mark.e2e_sharded_cluster_scale_shards +class TestShardedClusterScaleUpShards(KubernetesTester): + """ + name: ShardedCluster scale down of shards (sc) + description: | + Updates the sharded cluster, scaling up its shards count to 2. Makes sure no data is lost. + update: + file: sharded-cluster-scale-shards.yaml + patch: '[{"op":"replace","path":"/spec/shardCount", "value": 2}]' + wait_until: in_running_state + timeout: 360 + """ + + def test_db_data_the_same_count(self): + mongod_tester = ShardedClusterTester("sh001-scale-down-shards", 1) + + mongod_tester.assert_number_of_shards(2) + mongod_tester.assert_data_size(50_000) + + def test_statefulset_for_shard_added(self): + assert ( + self.appsv1.read_namespaced_stateful_set( + "sh001-scale-down-shards-1", self.namespace + ) + is not None + ) diff --git a/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_schema_validation.py b/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_schema_validation.py new file mode 100644 index 000000000..6cc9af565 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_schema_validation.py @@ -0,0 +1,149 @@ +import pytest +from kubetester.kubetester import KubernetesTester + + +@pytest.mark.e2e_sharded_cluster_schema_validation +class TestShardedClusterValidationMongosMissing(KubernetesTester): + """ + name: Sharded Cluster Validation (mongos missing) + create: + file: sharded-cluster.yaml + patch: '[{"op":"remove","path":"/spec/mongosCount"}]' + exception: 'Unprocessable Entity' + """ + + @pytest.mark.skip + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_sharded_cluster_schema_validation +class TestShardedClusterValidationShardCountMissing(KubernetesTester): + """ + name: Sharded Cluster Validation (shardCount missing) + create: + file: sharded-cluster.yaml + patch: '[{"op":"remove","path":"/spec/shardCount"}]' + exception: 'Unprocessable Entity' + """ + + @pytest.mark.skip + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_sharded_cluster_schema_validation +class TestShardedClusterValidationConfigServerCount(KubernetesTester): + """ + name: Sharded Cluster Validation (configServerCount missing) + create: + file: sharded-cluster.yaml + patch: '[{"op":"remove","path":"/spec/configServerCount"}]' + exception: 'Unprocessable Entity' + """ + + @pytest.mark.skip + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_sharded_cluster_schema_validation +class TestShardedClusterValidationMongoDsPerShard(KubernetesTester): + """ + name: Sharded Cluster Validation (mongodsPerShardCount missing) + create: + file: sharded-cluster.yaml + patch: '[{"op":"remove","path":"/spec/mongodsPerShardCount"}]' + exception: 'Unprocessable Entity' + """ + + @pytest.mark.skip + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_sharded_cluster_schema_validation +class TestShardedClusterValidationInvalidType(KubernetesTester): + """ + name: Sharded Cluster Validation (invalid type) + create: + file: sharded-cluster.yaml + patch: '[{"op":"replace","path":"/spec/type","value":"InvalidShardedClusterType"}]' + exception: 'Unprocessable Entity' + """ + + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_sharded_cluster_schema_validation +class TestShardedClusterValidationInvalidLogLevel(KubernetesTester): + """ + name: Sharded Cluster Validation (invalid logLevel) + create: + file: sharded-cluster.yaml + patch: '[{"op":"replace","path":"/spec/logLevel","value":"NotDEBUG"}]' + exception: 'Unprocessable Entity' + """ + + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_sharded_cluster_schema_validation +class TestShardedClusterSchemaAdditionalMongodConfigNotAllowed(KubernetesTester): + """ + name: Sharded Cluster Validation (additional Mongod Config not allowed) + create: + file: sharded-cluster.yaml + patch: '[{"op":"add","path":"/spec/additionalMongodConfig","value":{"net":{"ssl":{"mode": "disabled"}}}}]' + exception: 'cannot be specified if type of MongoDB is ShardedCluster' + """ + + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_sharded_cluster_schema_validation +class TestShardedClusterInvalidWithProjectAndOpsManager(KubernetesTester): + init = { + "create": { + "file": "sharded-cluster.yaml", + "patch": [ + { + "op": "add", + "path": "/spec/opsManager", + "value": {"configMapRef": {"name": "something"}}, + }, + ], + "exception": "must validate one and only one schema", + }, + } + + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_sharded_cluster_schema_validation +class TestShardedClusterInvalidWithCloudAndOpsManagerAndProject(KubernetesTester): + init = { + "create": { + "file": "sharded-cluster.yaml", + "patch": [ + { + "op": "add", + "path": "/spec/cloudManager", + "value": {"configMapRef": {"name": "something"}}, + }, + { + "op": "add", + "path": "/spec/opsManager", + "value": {"configMapRef": {"name": "something"}}, + }, + ], + "exception": "must validate one and only one schema", + }, + } + + def test_validation_ok(self): + assert True diff --git a/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_secret.py b/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_secret.py new file mode 100644 index 000000000..dffd2a810 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_secret.py @@ -0,0 +1,29 @@ +import pytest + +from kubernetes.client import V1Secret +from kubetester.kubetester import KubernetesTester + + +@pytest.mark.e2e_sharded_cluster_secret +class TestShardedClusterListensSecret(KubernetesTester): + """ + name: ShardedCluster tracks configmap changes + description: | + Creates a sharded cluster, then changes secret - breaks the api key and checks that the reconciliation for the | + standalone happened and it got into Failed state. Note, that this test cannot be run with 'make e2e .. light=true' | + flag locally as secret must be recreated + create: + file: sharded-cluster-single.yaml + wait_until: in_running_state + timeout: 240 + """ + + def test_patch_config_map(self): + secret = V1Secret(string_data={"publicApiKey": "wrongKey"}) + self.clients("corev1").patch_namespaced_secret( + "my-credentials", self.get_namespace(), secret + ) + + print('Patched the Secret - changed publicApiKey to "wrongKey"') + + KubernetesTester.wait_until("in_error_state", 20) diff --git a/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_statefulset_status.py b/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_statefulset_status.py new file mode 100644 index 000000000..7be33b203 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_statefulset_status.py @@ -0,0 +1,74 @@ +from pytest import fixture, mark +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.mongodb import MongoDB, Phase + + +@fixture(scope="module") +def sharded_cluster(namespace: str, custom_mdb_version: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("sharded-cluster-single.yaml"), + namespace=namespace, + name="sharded-cluster-status", + ) + resource.set_version(custom_mdb_version) + resource["spec"]["shardCount"] = 2 + return resource.create() + + +""" +This test checks the 'status.resourcesNotReady' element during sharded cluster reconciliation. It's expected to +be populated with the information about current StatefulSet pending in the following order: config server, shard 0, +shard 1, mongos. +""" + + +@mark.e2e_sharded_cluster_statefulset_status +def test_config_srv_reaches_pending_phase(sharded_cluster: MongoDB): + cluster_reaches_not_ready(sharded_cluster, sharded_cluster.name + "-config") + + +@mark.e2e_sharded_cluster_statefulset_status +def test_first_shard_reaches_pending_phase(sharded_cluster: MongoDB): + cluster_reaches_not_ready(sharded_cluster, sharded_cluster.name + "-0") + + +@mark.e2e_sharded_cluster_statefulset_status +def test_second_shard_reaches_pending_phase(sharded_cluster: MongoDB): + cluster_reaches_not_ready(sharded_cluster, sharded_cluster.name + "-1") + + +@mark.e2e_sharded_cluster_statefulset_status +def test_mongos_reaches_pending_phase(sharded_cluster: MongoDB): + cluster_reaches_not_ready(sharded_cluster, sharded_cluster.name + "-mongos") + + +@mark.e2e_sharded_cluster_statefulset_status +def test_sharded_cluster_reaches_running_phase(sharded_cluster: MongoDB): + # The 'status.resourcesNotReady' must get cleaned soon after the mongos StatefulSet is ready - then + # the resource will stay in 'Reconciling' phase for some time waiting for the agents to reach goal state + sharded_cluster.wait_for( + lambda s: s.get_status_resources_not_ready() is None, + timeout=150, + should_raise=True, + ) + assert sharded_cluster.get_status_phase() == Phase.Reconciling + + sharded_cluster.assert_reaches_phase(Phase.Running, timeout=100) + assert sharded_cluster.get_status_resources_not_ready() is None + + +def cluster_reaches_not_ready(sharded_cluster: MongoDB, sts_name: str): + """This function waits until the sharded cluster status gets 'resource_not_ready' element for the specified + StatefulSet""" + + def resource_not_ready(s: MongoDB): + if s.get_status_resources_not_ready() is None: + return False + return s.get_status_resources_not_ready()[0]["name"] == sts_name + + sharded_cluster.wait_for(resource_not_ready, timeout=150, should_raise=True) + sharded_cluster.assert_status_resource_not_ready( + sts_name, + msg_regexp="Not all the Pods are ready \(total: 1.*\)", + ) + assert sharded_cluster.get_status_phase() == Phase.Pending diff --git a/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_upgrade_downgrade.py b/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_upgrade_downgrade.py new file mode 100644 index 000000000..91920c8cd --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/shardedcluster/sharded_cluster_upgrade_downgrade.py @@ -0,0 +1,58 @@ +import pytest +from kubetester.kubetester import KubernetesTester +from kubetester.mongotester import ShardedClusterTester + + +@pytest.mark.e2e_sharded_cluster_upgrade_downgrade +class TestShardedClusterUpgradeDowngradeCreate(KubernetesTester): + """ + name: ShardedCluster upgrade downgrade (create) + description: | + Creates a sharded cluster, then upgrades it with compatibility version set and then downgrades back + create: + file: sharded-cluster-downgrade.yaml + wait_until: in_running_state + timeout: 300 + """ + + def test_db_connectable(self): + mongod_tester = ShardedClusterTester("sh001-downgrade", 1) + mongod_tester.assert_connectivity() + mongod_tester.assert_version("4.4.2") + + +@pytest.mark.e2e_sharded_cluster_upgrade_downgrade +class TestShardedClusterUpgradeDowngradeUpdate(KubernetesTester): + """ + name: ShardedCluster upgrade downgrade (update) + description: | + Updates a ShardedCluster to bigger version, leaving feature compatibility version as it was + update: + file: sharded-cluster-downgrade.yaml + patch: '[{"op":"replace","path":"/spec/version", "value": "4.4.0"}, {"op":"add","path":"/spec/featureCompatibilityVersion", "value": "4.4"}]' + wait_until: in_running_state + timeout: 300 + """ + + def test_db_connectable(self): + mongod_tester = ShardedClusterTester("sh001-downgrade", 1) + mongod_tester.assert_connectivity() + mongod_tester.assert_version("4.4.0") + + +@pytest.mark.e2e_sharded_cluster_upgrade_downgrade +class TestShardedClusterUpgradeDowngradeRevert(KubernetesTester): + """ + name: ShardedCluster upgrade downgrade (downgrade) + description: | + Updates a ShardedCluster to the same version it was created initially + update: + file: sharded-cluster-downgrade.yaml + wait_until: in_running_state + timeout: 500 + """ + + def test_db_connectable(self): + mongod_tester = ShardedClusterTester("sh001-downgrade", 1) + mongod_tester.assert_connectivity() + mongod_tester.assert_version("4.4.2") diff --git a/docker/mongodb-enterprise-tests/tests/standalone/__init__.py b/docker/mongodb-enterprise-tests/tests/standalone/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/docker/mongodb-enterprise-tests/tests/standalone/conftest.py b/docker/mongodb-enterprise-tests/tests/standalone/conftest.py new file mode 100644 index 000000000..cb0664057 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/standalone/conftest.py @@ -0,0 +1,4 @@ +def pytest_runtest_setup(item): + """This allows to automatically install the default Operator before running any test""" + if "default_operator" not in item.fixturenames: + item.fixturenames.insert(0, "default_operator") diff --git a/docker/mongodb-enterprise-tests/tests/standalone/fixtures/standalone-custom-podspec.yaml b/docker/mongodb-enterprise-tests/tests/standalone/fixtures/standalone-custom-podspec.yaml new file mode 100644 index 000000000..871d2191a --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/standalone/fixtures/standalone-custom-podspec.yaml @@ -0,0 +1,31 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: my-standalone-custom-podspec +spec: + type: Standalone + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + persistent: false + podSpec: + podTemplate: + metadata: + labels: + label1: "value1" + spec: + containers: + - name: standalone-sidecar + image: busybox + command: ["sleep"] + args: [ "infinity" ] + terminationGracePeriodSeconds: 10 + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - podAffinityTerm: + topologyKey: "mykey" + weight: 50 diff --git a/docker/mongodb-enterprise-tests/tests/standalone/fixtures/standalone-downgrade.yaml b/docker/mongodb-enterprise-tests/tests/standalone/fixtures/standalone-downgrade.yaml new file mode 100644 index 000000000..456d02793 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/standalone/fixtures/standalone-downgrade.yaml @@ -0,0 +1,12 @@ +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: my-standalone-downgrade +spec: + version: 4.4.2 + type: Standalone + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + persistent: false diff --git a/docker/mongodb-enterprise-tests/tests/standalone/fixtures/standalone.yaml b/docker/mongodb-enterprise-tests/tests/standalone/fixtures/standalone.yaml new file mode 100644 index 000000000..7e6d4a476 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/standalone/fixtures/standalone.yaml @@ -0,0 +1,13 @@ +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: my-standalone +spec: + version: 5.0.5-ent + type: Standalone + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + persistent: false + logLevel: INFO diff --git a/docker/mongodb-enterprise-tests/tests/standalone/fixtures/standalone_pv_invalid.yaml b/docker/mongodb-enterprise-tests/tests/standalone/fixtures/standalone_pv_invalid.yaml new file mode 100644 index 000000000..be1c3f78d --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/standalone/fixtures/standalone_pv_invalid.yaml @@ -0,0 +1,17 @@ +# This is a standalone that references the non-existent storageClass "foo" which is created eventually +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: my-replica-set-vol-broken +spec: + type: Standalone + version: 5.0.5-ent + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + persistent: true + podSpec: + persistence: + single: + storage: 2G diff --git a/docker/mongodb-enterprise-tests/tests/standalone/fixtures/test_storage_class.yaml b/docker/mongodb-enterprise-tests/tests/standalone/fixtures/test_storage_class.yaml new file mode 100644 index 000000000..6c9596751 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/standalone/fixtures/test_storage_class.yaml @@ -0,0 +1,11 @@ +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + labels: + k8s-addon: storage-aws.addons.k8s.io + name: foo +parameters: + type: gp2 +provisioner: kubernetes.io/aws-ebs +reclaimPolicy: Delete +volumeBindingMode: Immediate diff --git a/docker/mongodb-enterprise-tests/tests/standalone/standalone_config_map.py b/docker/mongodb-enterprise-tests/tests/standalone/standalone_config_map.py new file mode 100644 index 000000000..573657b03 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/standalone/standalone_config_map.py @@ -0,0 +1,59 @@ +import pytest +import time +from kubernetes import client +from kubernetes.client import V1ConfigMap, V1ObjectMeta +from pytest import fixture + +from kubetester.kubetester import KubernetesTester +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.mongodb import MongoDB, Phase + + +@fixture(scope="module") +def standalone(namespace: str) -> MongoDB: + resource = MongoDB.from_yaml(yaml_fixture("standalone.yaml"), namespace=namespace) + return resource.create() + + +@fixture(scope="module") +def new_project_name(standalone: MongoDB) -> str: + yield KubernetesTester.random_om_project_name() + # Cleaning the new group in any case - the updated config map will be used to get the new name + print("\nRemoving the generated group from Ops Manager/Cloud Manager") + print(standalone.get_om_tester().api_remove_group()) + + +@pytest.mark.e2e_standalone_config_map +class TestStandaloneListensConfigMap: + group_id = "" + + def test_create_standalone(self, standalone: MongoDB): + standalone.assert_reaches_phase(Phase.Running, timeout=150) + + def test_patch_config_map(self, standalone: MongoDB, new_project_name: str): + # saving the group id for later check + TestStandaloneListensConfigMap.group_id = ( + standalone.get_om_tester().find_group_id() + ) + + config_map = V1ConfigMap(data={"projectName": new_project_name}) + client.CoreV1Api().patch_namespaced_config_map( + standalone.config_map_name, standalone.namespace, config_map + ) + + print( + '\nPatched the ConfigMap - changed group name to "{}"'.format( + new_project_name + ) + ) + + def test_standalone_handles_changes(self, standalone: MongoDB): + standalone.assert_abandons_phase(phase=Phase.Running) + standalone.assert_reaches_phase(Phase.Running, timeout=200) + + def test_new_group_was_created(self, standalone: MongoDB): + # Checking that the new group was created in OM + assert ( + standalone.get_om_tester().find_group_id() + != TestStandaloneListensConfigMap.group_id + ) diff --git a/docker/mongodb-enterprise-tests/tests/standalone/standalone_custom_podspec.py b/docker/mongodb-enterprise-tests/tests/standalone/standalone_custom_podspec.py new file mode 100644 index 000000000..5c32d55ec --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/standalone/standalone_custom_podspec.py @@ -0,0 +1,36 @@ +from kubetester.kubetester import fixture as yaml_fixture, KubernetesTester +from kubetester.mongodb import MongoDB, Phase +from kubetester.custom_podspec import assert_stateful_set_podspec +from pytest import fixture, mark + + +@fixture(scope="module") +def standalone(namespace: str, custom_mdb_version: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("standalone-custom-podspec.yaml"), namespace=namespace + ) + resource.set_version(custom_mdb_version) + return resource.create() + + +@mark.e2e_standalone_custom_podspec +def test_replica_set_reaches_running_phase(standalone): + standalone.assert_reaches_phase(Phase.Running, timeout=600) + + +@mark.e2e_standalone_custom_podspec +def test_stateful_set_spec_updated(standalone, namespace): + appsv1 = KubernetesTester.clients("appsv1") + sts = appsv1.read_namespaced_stateful_set(standalone.name, namespace) + assert_stateful_set_podspec( + sts.spec.template.spec, weight=50, topology_key="mykey", grace_period_seconds=10 + ) + + containers = sts.spec.template.spec.containers + + assert len(containers) == 2 + assert containers[0].name == "mongodb-enterprise-database" + assert containers[1].name == "standalone-sidecar" + + labels = sts.spec.template.metadata.labels + assert labels["label1"] == "value1" diff --git a/docker/mongodb-enterprise-tests/tests/standalone/standalone_groups.py b/docker/mongodb-enterprise-tests/tests/standalone/standalone_groups.py new file mode 100644 index 000000000..0296773a0 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/standalone/standalone_groups.py @@ -0,0 +1,88 @@ +import pytest + +from kubetester.kubetester import ( + KubernetesTester, + fixture, + MAX_TAG_LEN, + EXTERNALLY_MANAGED_TAG, +) +from kubetester.omtester import skip_if_cloud_manager, should_include_tag + + +@pytest.mark.e2e_standalone_groups +class TestStandaloneOrganizationSpecified(KubernetesTester): + """ + name: Test for config map with specified organization id. + description: | + Tests the configuration in config map with organization specified which already exists. The group + must be created automatically. + Skipped in Cloud Manager as programmatic API keys won't allow for organizations to be created. + """ + + org_id = None + org_name = None + + @classmethod + def setup_env(cls): + # Create some organization and update the config map with its organization id + cls.org_name = KubernetesTester.random_k8s_name("standalone-group-test-") + cls.org_id = cls.create_organization(cls.org_name) + cls.patch_config_map(cls.get_namespace(), "my-project", {"orgId": cls.org_id}) + + print("Patched config map, now it references organization " + cls.org_id) + + # todo + @pytest.mark.skip( + reason="project reconciliation adds some flakiness - sometimes it gets on time to create the " + "project in OM before this method is called - should be fixed by one project" + ) + def test_standalone_created_organization_found(self): + groups_in_org = self.get_groups_in_organization_first_page( + self.__class__.org_id + )["totalCount"] + + # no group is created when organization is created + assert groups_in_org == 0 + + def test_standalone_cr_is_created(self): + # Create a standalone - the organization will be found and new group will be created + self.create_custom_resource_from_file( + self.get_namespace(), fixture("standalone.yaml") + ) + + KubernetesTester.wait_until("in_running_state", 150) + + def test_standalone_organizations_are_found(self): + # Making sure no more organizations were created but the group was created inside the organization + assert len(self.find_organizations(self.__class__.org_name)) == 1 + print( + 'Only one organization with name "{}" exists (as expected)'.format( + self.__class__.org_name + ) + ) + + def test_standalone_get_groups_in_orgs(self): + page = self.get_groups_in_organization_first_page(self.__class__.org_id) + assert page["totalCount"] == 1 + group = page["results"][0] + assert group is not None + assert group["orgId"] == self.__class__.org_id + + print( + 'The group "{}" has been created by the Operator in organization "{}"'.format( + self.get_om_group_name(), self.__class__.org_name + ), + ) + + @skip_if_cloud_manager() + def test_group_tag_was_set(self): + page = self.get_groups_in_organization_first_page(self.__class__.org_id) + group = page["results"][0] + + version = KubernetesTester.om_version() + expected_tags = [self.namespace[:MAX_TAG_LEN].upper()] + + if should_include_tag(version): + expected_tags.append(EXTERNALLY_MANAGED_TAG) + + assert sorted(group["tags"]) == sorted(expected_tags) diff --git a/docker/mongodb-enterprise-tests/tests/standalone/standalone_recovery.py b/docker/mongodb-enterprise-tests/tests/standalone/standalone_recovery.py new file mode 100644 index 000000000..7843b0c3f --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/standalone/standalone_recovery.py @@ -0,0 +1,39 @@ +import yaml +import pytest + +from kubernetes.client import V1ConfigMap +from kubetester.kubetester import KubernetesTester, fixture + + +@pytest.mark.e2e_standalone_recovery +class TestStandaloneRecoversBadOmConfiguration(KubernetesTester): + """ + name: Standalone broken OM connection + description: | + Creates a standalone with a bad OM connection (ConfigMap is broken) and ensures it enters a failed state | + Then the config map is fixed and the standalone is expected to reach good state eventually + """ + + def test_standalone_reaches_failed_state(self): + config_map = V1ConfigMap(data={"baseUrl": "http://foo.bar"}) + KubernetesTester.clients("corev1").patch_namespaced_config_map( + "my-project", KubernetesTester.get_namespace(), config_map + ) + + resource = yaml.safe_load(open(fixture("standalone.yaml"))) + + KubernetesTester.create_mongodb_from_object( + KubernetesTester.get_namespace(), resource + ) + + KubernetesTester.wait_until("in_error_state", 20) + mrs = KubernetesTester.get_resource() + assert "Failed to prepare Ops Manager connection" in mrs["status"]["message"] + + def test_recovery(self): + config_map = V1ConfigMap(data={"baseUrl": KubernetesTester.get_om_base_url()}) + KubernetesTester.clients("corev1").patch_namespaced_config_map( + "my-project", KubernetesTester.get_namespace(), config_map + ) + + KubernetesTester.wait_until("in_running_state_failures_possible", 180) diff --git a/docker/mongodb-enterprise-tests/tests/standalone/standalone_schema_validation.py b/docker/mongodb-enterprise-tests/tests/standalone/standalone_schema_validation.py new file mode 100644 index 000000000..2709b2ecd --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/standalone/standalone_schema_validation.py @@ -0,0 +1,117 @@ +import pytest +from kubetester.kubetester import KubernetesTester + + +@pytest.mark.e2e_standalone_schema_validation +class TestStandaloneSchemaCredentialsMissing(KubernetesTester): + """ + name: Validation for standalone (credentials missing) + create: + file: standalone.yaml + patch: '[{"op":"remove","path":"/spec/credentials"}]' + exception: 'Unprocessable Entity' + """ + + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_standalone_schema_validation +class TestStandaloneSchemaVersionMissing(KubernetesTester): + """ + name: Validation for standalone (version missing) + create: + file: standalone.yaml + patch: '[{"op":"remove","path":"/spec/version"}]' + exception: 'Unprocessable Entity' + """ + + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_standalone_schema_validation +class TestStandaloneSchemaInvalidType(KubernetesTester): + """ + name: Validation for standalone (invalid type) + create: + file: standalone.yaml + patch: '[{"op":"replace","path":"/spec/type","value":"InvalidStandaloneType"}]' + exception: 'Unprocessable Entity' + """ + + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_standalone_schema_validation +class TestStandaloneSchemaInvalidLogLevel(KubernetesTester): + """ + name: Validation for standalone (invalid logLevel) + create: + file: standalone.yaml + patch: '[{"op":"replace","path":"/spec/logLevel","value":"NotINFO"}]' + exception: 'Unprocessable Entity' + """ + + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_standalone_schema_validation +class TestStandaloneSchemaShardedClusterMongodConfig(KubernetesTester): + """ + name: Validation for standalone (sharded cluster additional mongod config) + create: + file: standalone.yaml + patch: '[{"op":"add","path":"/spec/shard","value":{"operationProfiling":{"mode":true}}}]' + exception: 'cannot be specified if type of MongoDB is Standalone' + """ + + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_standalone_schema_validation +class TestStandaloneInvalidWithProjectAndCloudManager(KubernetesTester): + init = { + "create": { + "file": "standalone.yaml", + "patch": [ + { + "op": "add", + "path": "/spec/cloudManager", + "value": {"configMapRef": {"name": "something"}}, + }, + ], + "exception": "must validate one and only one schema", + }, + } + + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_standalone_schema_validation +class TestStandaloneInvalidWithCloudAndOpsManagerAndProject(KubernetesTester): + init = { + "create": { + "file": "standalone.yaml", + "patch": [ + { + "op": "add", + "path": "/spec/cloudManager", + "value": {"configMapRef": {"name": "something"}}, + }, + { + "op": "add", + "path": "/spec/opsManager", + "value": {"configMapRef": {"name": "something"}}, + }, + ], + "exception": "must validate one and only one schema", + }, + } + + def test_validation_ok(self): + assert True diff --git a/docker/mongodb-enterprise-tests/tests/standalone/standalone_set_agent_flags.py b/docker/mongodb-enterprise-tests/tests/standalone/standalone_set_agent_flags.py new file mode 100644 index 000000000..5f4570d04 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/standalone/standalone_set_agent_flags.py @@ -0,0 +1,38 @@ +from pytest import mark, fixture + +from kubetester import find_fixture + +from kubetester.mongodb import MongoDB, Phase + +from kubetester.kubetester import KubernetesTester + + +@fixture(scope="module") +def standalone(namespace: str) -> MongoDB: + resource = MongoDB.from_yaml(find_fixture("standalone.yaml"), namespace=namespace) + + resource["spec"]["agent"] = { + "startupOptions": {"logFile": "/var/log/mongodb-mms-automation/customLogFile"} + } + + return resource.create() + + +@mark.e2e_standalone_agent_flags +def test_standalone(standalone: MongoDB): + standalone.assert_reaches_phase(Phase.Running, timeout=400) + + +@mark.e2e_standalone_agent_flags +def test_standalone_has_agent_flags(standalone: MongoDB, namespace: str): + cmd = [ + "/bin/sh", + "-c", + "ls /var/log/mongodb-mms-automation/customLogFile* | wc -l", + ] + result = KubernetesTester.run_command_in_pod_container( + "my-standalone-0", + namespace, + cmd, + ) + assert result != "0" diff --git a/docker/mongodb-enterprise-tests/tests/standalone/standalone_type_change_recovery.py b/docker/mongodb-enterprise-tests/tests/standalone/standalone_type_change_recovery.py new file mode 100644 index 000000000..c43730e96 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/standalone/standalone_type_change_recovery.py @@ -0,0 +1,42 @@ +import pytest +from kubetester import MongoDB +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.mongodb import Phase +from pytest import fixture + + +@fixture(scope="module") +def standalone(namespace: str, custom_mdb_version: str) -> MongoDB: + resource = MongoDB.from_yaml(yaml_fixture("standalone.yaml"), "my-standalone", namespace) + resource.set_version(custom_mdb_version) + resource.create() + + return resource + + +@pytest.mark.e2e_standalone_type_change_recovery +def test_standalone_created(standalone: MongoDB): + standalone.assert_reaches_phase(phase=Phase.Running) + + +@pytest.mark.e2e_standalone_type_change_recovery +def test_break_standalone(standalone: MongoDB): + """Changes persistence configuration - this is not allowed by StatefulSet""" + standalone.load() + # Unfortunately even breaking the podtemplate won't get the resource into Failed state as it will just hang in Pending + # standalone["spec"]["podSpec"] = {"podTemplate": {"spec": {"containers": [{"image": "broken", "name": "mongodb-enterprise-database"}]}}} + standalone["spec"]["persistent"] = True + standalone.update() + standalone.assert_reaches_phase( + phase=Phase.Failed, + msg_regexp=".*can't execute update on forbidden fields.*", + timeout=60, + ) + + +@pytest.mark.e2e_standalone_type_change_recovery +def test_fix_standalone(standalone: MongoDB): + standalone.load() + standalone["spec"]["persistent"] = False + standalone.update() + standalone.assert_reaches_phase(phase=Phase.Running) diff --git a/docker/mongodb-enterprise-tests/tests/standalone/standalone_upgrade_downgrade.py b/docker/mongodb-enterprise-tests/tests/standalone/standalone_upgrade_downgrade.py new file mode 100644 index 000000000..e4cd9dd7c --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/standalone/standalone_upgrade_downgrade.py @@ -0,0 +1,67 @@ +import pytest +from kubetester.kubetester import KubernetesTester, skip_if_local +from kubetester.mongotester import StandaloneTester + + +@pytest.mark.e2e_standalone_upgrade_downgrade +class TestStandaloneUpgradeDowngradeCreate(KubernetesTester): + """ + name: Standalone upgrade downgrade (create) + description: | + Creates a standalone, then upgrades it with compatibility version set and then downgrades back + create: + file: standalone-downgrade.yaml + wait_until: in_running_state + timeout: 200 + """ + + @skip_if_local + def test_db_connectable(self): + mongod_tester = StandaloneTester("my-standalone-downgrade") + mongod_tester.assert_version("4.4.2") + + def test_noop(self): + assert True + + +@pytest.mark.e2e_standalone_upgrade_downgrade +class TestStandaloneUpgradeDowngradeUpdate(KubernetesTester): + """ + name: Standalone upgrade downgrade (update) + description: | + Updates a Standalone to bigger version, leaving feature compatibility version as it was + update: + file: standalone-downgrade.yaml + patch: '[{"op":"replace","path":"/spec/version", "value": "4.4.0"}, {"op":"add","path":"/spec/featureCompatibilityVersion", "value": "4.4"}]' + wait_until: in_running_state + timeout: 200 + """ + + @skip_if_local + def test_db_connectable(self): + mongod_tester = StandaloneTester("my-standalone-downgrade") + mongod_tester.assert_version("4.4.0") + + def test_noop(self): + assert True + + +@pytest.mark.e2e_standalone_upgrade_downgrade +class TestStandaloneUpgradeDowngradeRevert(KubernetesTester): + """ + name: Standalone upgrade downgrade (downgrade) + description: | + Updates a Standalone to the same version it was created initially + update: + file: standalone-downgrade.yaml + wait_until: in_running_state + timeout: 200 + """ + + @skip_if_local + def test_db_connectable(self): + mongod_tester = StandaloneTester("my-standalone-downgrade") + mongod_tester.assert_version("4.4.2") + + def test_noop(self): + assert True diff --git a/docker/mongodb-enterprise-tests/tests/tls/__init__.py b/docker/mongodb-enterprise-tests/tests/tls/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/docker/mongodb-enterprise-tests/tests/tls/conftest.py b/docker/mongodb-enterprise-tests/tests/tls/conftest.py new file mode 100644 index 000000000..cb0664057 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/conftest.py @@ -0,0 +1,4 @@ +def pytest_runtest_setup(item): + """This allows to automatically install the default Operator before running any test""" + if "default_operator" not in item.fixturenames: + item.fixturenames.insert(0, "default_operator") diff --git a/docker/mongodb-enterprise-tests/tests/tls/e2e_configure_tls_and_x509_simultaneously_rs.py b/docker/mongodb-enterprise-tests/tests/tls/e2e_configure_tls_and_x509_simultaneously_rs.py new file mode 100644 index 000000000..037338b2b --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/e2e_configure_tls_and_x509_simultaneously_rs.py @@ -0,0 +1,56 @@ +import pytest + +from kubetester.kubetester import KubernetesTester +from kubetester.mongotester import ReplicaSetTester +from kubetester.omtester import get_rs_cert_names +from kubetester.mongodb import MongoDB, Phase +from kubetester.certs import ( + Certificate, + ISSUER_CA_NAME, + create_mongodb_tls_certs, + create_agent_tls_certs, +) +from kubetester.kubetester import fixture as load_fixture + +MDB_RESOURCE = "my-replica-set" + + +@pytest.fixture(scope="module") +def server_certs(issuer: str, namespace: str): + return create_mongodb_tls_certs( + ISSUER_CA_NAME, namespace, MDB_RESOURCE, f"{MDB_RESOURCE}-cert" + ) + + +@pytest.fixture(scope="module") +def mdb(namespace: str, server_certs: str, issuer_ca_configmap: str) -> MongoDB: + res = MongoDB.from_yaml(load_fixture("replica-set.yaml"), namespace=namespace) + res["spec"]["security"] = {"tls": {"ca": issuer_ca_configmap}} + return res.create() + + +@pytest.fixture(scope="module") +def agent_certs(issuer: str, namespace: str) -> str: + return create_agent_tls_certs(issuer, namespace, MDB_RESOURCE) + + +@pytest.mark.e2e_configure_tls_and_x509_simultaneously_rs +def test_mdb_running(mdb: MongoDB): + mdb.assert_reaches_phase(Phase.Running, timeout=400) + + +@pytest.mark.e2e_configure_tls_and_x509_simultaneously_rs +def test_connectivity(): + tester = ReplicaSetTester(MDB_RESOURCE, 3) + tester.assert_connectivity() + + +@pytest.mark.e2e_configure_tls_and_x509_simultaneously_rs +def test_enable_x509(mdb: MongoDB, agent_certs: str): + mdb.load() + mdb["spec"]["security"] = { + "authentication": {"enabled": True}, + "modes": ["X509"], + } + + mdb.assert_reaches_phase(Phase.Running, timeout=400) diff --git a/docker/mongodb-enterprise-tests/tests/tls/e2e_configure_tls_and_x509_simultaneously_sc.py b/docker/mongodb-enterprise-tests/tests/tls/e2e_configure_tls_and_x509_simultaneously_sc.py new file mode 100644 index 000000000..1a5947267 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/e2e_configure_tls_and_x509_simultaneously_sc.py @@ -0,0 +1,63 @@ +import pytest + +from kubetester.kubetester import KubernetesTester +from kubetester.mongotester import ShardedClusterTester +from kubetester.omtester import get_sc_cert_names +from kubetester.mongodb import MongoDB, Phase +from kubetester.certs import ( + Certificate, + ISSUER_CA_NAME, + create_mongodb_tls_certs, + create_agent_tls_certs, + create_sharded_cluster_certs, +) +from kubetester.kubetester import fixture as load_fixture + + +MDB_RESOURCE = "sh001-base" + + +@pytest.fixture(scope="module") +def server_certs(issuer: str, namespace: str) -> str: + create_sharded_cluster_certs( + namespace, + MDB_RESOURCE, + shards=1, + mongos_per_shard=3, + config_servers=3, + mongos=2, + ) + + +@pytest.fixture(scope="module") +def mdb(namespace: str, server_certs: str, issuer_ca_configmap: str) -> MongoDB: + res = MongoDB.from_yaml(load_fixture("sharded-cluster.yaml"), namespace=namespace) + res["spec"]["security"] = {"tls": {"ca": issuer_ca_configmap}} + return res.create() + + +@pytest.fixture(scope="module") +def agent_certs(issuer: str, namespace: str) -> str: + return create_agent_tls_certs(issuer, namespace, MDB_RESOURCE) + + +@pytest.mark.e2e_configure_tls_and_x509_simultaneously_sc +def test_standalone_running(mdb: MongoDB): + mdb.assert_reaches_phase(Phase.Running, timeout=400) + + +@pytest.mark.e2e_configure_tls_and_x509_simultaneously_sc +def test_connectivity(): + tester = ShardedClusterTester(MDB_RESOURCE, 2) + tester.assert_connectivity() + + +@pytest.mark.e2e_configure_tls_and_x509_simultaneously_sc +def test_enable_x509(mdb: MongoDB, agent_certs: str): + mdb.load() + mdb["spec"]["security"] = { + "authentication": {"enabled": True}, + "modes": ["X509"], + } + + mdb.assert_reaches_phase(Phase.Running, timeout=400) diff --git a/docker/mongodb-enterprise-tests/tests/tls/e2e_configure_tls_and_x509_simultaneously_standalone.py b/docker/mongodb-enterprise-tests/tests/tls/e2e_configure_tls_and_x509_simultaneously_standalone.py new file mode 100644 index 000000000..acd133a0d --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/e2e_configure_tls_and_x509_simultaneously_standalone.py @@ -0,0 +1,53 @@ +import pytest + +from kubetester.mongotester import StandaloneTester +from kubetester.kubetester import fixture as load_fixture +from kubetester.mongodb import MongoDB, Phase +from kubetester.certs import ( + ISSUER_CA_NAME, + create_mongodb_tls_certs, + create_agent_tls_certs, +) + +MDB_RESOURCE = "my-standalone" + + +@pytest.fixture(scope="module") +def server_certs(issuer: str, namespace: str): + return create_mongodb_tls_certs( + ISSUER_CA_NAME, namespace, MDB_RESOURCE, f"{MDB_RESOURCE}-cert", replicas=1 + ) + + +@pytest.fixture(scope="module") +def mdb(namespace: str, server_certs: str, issuer_ca_configmap: str) -> MongoDB: + res = MongoDB.from_yaml(load_fixture("standalone.yaml"), namespace=namespace) + res["spec"]["security"] = {"tls": {"ca": issuer_ca_configmap}} + return res.create() + + +@pytest.fixture(scope="module") +def agent_certs(issuer: str, namespace: str) -> str: + return create_agent_tls_certs(issuer, namespace, MDB_RESOURCE) + + +@pytest.mark.e2e_configure_tls_and_x509_simultaneously_st +def test_mdb_running(mdb: MongoDB): + mdb.assert_reaches_phase(Phase.Running, timeout=400) + + +@pytest.mark.e2e_configure_tls_and_x509_simultaneously_st +def test_connectivity(): + tester = StandaloneTester(MDB_RESOURCE) + tester.assert_connectivity() + + +@pytest.mark.e2e_configure_tls_and_x509_simultaneously_st +def test_enable_x509(mdb: MongoDB, agent_certs: str): + mdb.load() + mdb["spec"]["security"] = { + "authentication": {"enabled": True}, + "modes": ["X509"], + } + + mdb.assert_reaches_phase(Phase.Running, timeout=400) diff --git a/docker/mongodb-enterprise-tests/tests/tls/e2e_tls_disable_and_scale_up.py b/docker/mongodb-enterprise-tests/tests/tls/e2e_tls_disable_and_scale_up.py new file mode 100644 index 000000000..db60032a1 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/e2e_tls_disable_and_scale_up.py @@ -0,0 +1,51 @@ +import pytest +from kubetester.kubetester import fixture as load_fixture +from kubetester.certs import Certificate, ISSUER_CA_NAME, create_mongodb_tls_certs +from kubetester.mongodb import MongoDB, Phase +from kubetester import create_secret, read_secret + + +@pytest.fixture(scope="module") +def server_certs(issuer: str, namespace: str): + resource_name = "test-tls-base-rs" + return create_mongodb_tls_certs( + ISSUER_CA_NAME, namespace, resource_name, "test-tls-base-rs-cert" + ) + + +@pytest.fixture(scope="module") +def replica_set(namespace: str, server_certs: str, issuer_ca_configmap: str) -> MongoDB: + res = MongoDB.from_yaml(load_fixture("test-tls-base-rs.yaml"), namespace=namespace) + res["spec"]["security"]["tls"]["ca"] = issuer_ca_configmap + + # Set this ReplicaSet to allowSSL mode + # this is the only mode that can go to "disabled" state. + res["spec"]["additionalMongodConfig"] = {"net": {"ssl": {"mode": "allowSSL"}}} + + return res.create() + + +@pytest.mark.e2e_disable_tls_scale_up +def test_rs_is_running(replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running, timeout=400) + + +@pytest.mark.e2e_disable_tls_scale_up +def test_tls_is_disabled_and_scaled_up(replica_set: MongoDB): + replica_set.load() + replica_set["spec"]["members"] = 5 + + replica_set.update() + + +@pytest.mark.e2e_disable_tls_scale_up +def test_tls_is_disabled_and_scaled_up(replica_set: MongoDB): + replica_set.load() + replica_set["spec"]["security"]["tls"]["enabled"] = False + del replica_set["spec"]["additionalMongodConfig"] + + replica_set.update() + + # timeout is longer because the operator first needs to + # disable TLS and then, scale down one by one. + replica_set.assert_reaches_phase(Phase.Running, timeout=800) diff --git a/docker/mongodb-enterprise-tests/tests/tls/fixtures/node-port-service.yaml b/docker/mongodb-enterprise-tests/tests/tls/fixtures/node-port-service.yaml new file mode 100644 index 000000000..11e1db724 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/fixtures/node-port-service.yaml @@ -0,0 +1,17 @@ +--- +apiVersion: v1 +kind: Service +metadata: + labels: + controller: mongodb-enterprise-operator + name: test-tls-base-rs-external-access-svc-external +spec: + ports: + - nodePort: + port: 27017 + protocol: TCP + targetPort: 27017 + type: NodePort + selector: + controller: mongodb-enterprise-operator + statefulset.kubernetes.io/pod-name: -0 diff --git a/docker/mongodb-enterprise-tests/tests/tls/fixtures/server-key.pem b/docker/mongodb-enterprise-tests/tests/tls/fixtures/server-key.pem new file mode 100644 index 000000000..ba2717f65 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/fixtures/server-key.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIIpjJQ4k1yWb3qTxO2BUrw8uSGVdyWyaY+PYIc8Ui2mboAoGCCqGSM49 +AwEHoUQDQgAEKKjALmtc9/ZYqTn7ADAqpdiEDWmwTfSPAvQSHWHTRRlefeStVLl2 +XOVT9iy1jhgdI43uNqc2AoQwzmW0Gz+U+g== +-----END EC PRIVATE KEY----- diff --git a/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-no-tls-no-status.yaml b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-no-tls-no-status.yaml new file mode 100644 index 000000000..62b28157b --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-no-tls-no-status.yaml @@ -0,0 +1,16 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: test-no-tls-no-status +spec: + members: 3 + version: 4.4.2 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + + persistent: false diff --git a/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-additional-domains.yaml b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-additional-domains.yaml new file mode 100644 index 000000000..7a27a3180 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-additional-domains.yaml @@ -0,0 +1,21 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: test-tls-additional-domains +spec: + members: 3 + version: 4.4.0 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + + persistent: false + security: + tls: + enabled: true + additionalCertificateDomains: + - "additional-cert-test.com" diff --git a/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs-allow-ssl.yaml b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs-allow-ssl.yaml new file mode 100644 index 000000000..775290297 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs-allow-ssl.yaml @@ -0,0 +1,25 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: test-tls-base-rs-allow-ssl +spec: + members: 3 + version: 4.4.2 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + + persistent: false + + security: + tls: + enabled: true + + additionalMongodConfig: + net: + ssl: + mode: "allowSSL" diff --git a/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs-external-access.yaml b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs-external-access.yaml new file mode 100644 index 000000000..67c6419c0 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs-external-access.yaml @@ -0,0 +1,25 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: test-tls-base-rs-external-access +spec: + members: 3 + version: 4.4.0 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + + persistent: true + security: + tls: + enabled: true + + connectivity: + replicaSetHorizons: + - "test-horizon": "mdb0-test.website.com:1337" + - "test-horizon": "mdb1-test.website.com:1337" + - "test-horizon": "mdb2-test.website.com:1337" diff --git a/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs-prefer-ssl.yaml b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs-prefer-ssl.yaml new file mode 100644 index 000000000..75da1b6d4 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs-prefer-ssl.yaml @@ -0,0 +1,24 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: test-tls-base-rs-prefer-ssl +spec: + members: 3 + version: 4.4.2 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + + persistent: true + security: + tls: + enabled: true + + additionalMongodConfig: + net: + ssl: + mode: "preferSSL" diff --git a/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs-require-ssl-custom-ca.yaml b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs-require-ssl-custom-ca.yaml new file mode 100644 index 000000000..cda418439 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs-require-ssl-custom-ca.yaml @@ -0,0 +1,20 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: test-tls-base-rs-require-ssl +spec: + members: 3 + version: 4.4.0 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + + persistent: false + security: + tls: + enabled: true + ca: customer-ca diff --git a/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs-require-ssl-upgrade.yaml b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs-require-ssl-upgrade.yaml new file mode 100644 index 000000000..8bd76266f --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs-require-ssl-upgrade.yaml @@ -0,0 +1,22 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: test-tls-upgrade +spec: + members: 3 + version: 4.4.2 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + + persistent: true + + + additionalMongodConfig: + net: + ssl: + mode: "allowSSL" diff --git a/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs-require-ssl.yaml b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs-require-ssl.yaml new file mode 100644 index 000000000..0ddce091f --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs-require-ssl.yaml @@ -0,0 +1,19 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: test-tls-base-rs-require-ssl +spec: + members: 3 + version: 4.4.0 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + + persistent: true + security: + tls: + enabled: true diff --git a/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs-x509.yaml b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs-x509.yaml new file mode 100644 index 000000000..f0230b69f --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs-x509.yaml @@ -0,0 +1,22 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: test-tls-base-rs-x509 +spec: + members: 3 + version: 4.4.0 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + + persistent: false + security: + tls: + enabled: true + authentication: + enabled: true + modes: ["X509"] diff --git a/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs.yaml b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs.yaml new file mode 100644 index 000000000..82214adfb --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-rs.yaml @@ -0,0 +1,19 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: test-tls-base-rs +spec: + members: 3 + version: 4.2.2 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + + persistent: false + security: + tls: + enabled: true diff --git a/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-sc-require-ssl-custom-ca.yaml b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-sc-require-ssl-custom-ca.yaml new file mode 100644 index 000000000..388b59f69 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-sc-require-ssl-custom-ca.yaml @@ -0,0 +1,22 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: test-tls-base-sc-require-ssl +spec: + shardCount: 1 + mongodsPerShardCount: 3 + mongosCount: 2 + configServerCount: 3 + version: 4.4.0 + type: ShardedCluster + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + persistent: false + security: + tls: + enabled: true + ca: customer-ca diff --git a/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-sc-require-ssl.yaml b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-sc-require-ssl.yaml new file mode 100644 index 000000000..e59d1f0d0 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-base-sc-require-ssl.yaml @@ -0,0 +1,21 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: test-tls-base-sc-require-ssl +spec: + shardCount: 1 + mongodsPerShardCount: 3 + mongosCount: 2 + configServerCount: 3 + version: 4.4.0 + type: ShardedCluster + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + persistent: false + security: + tls: + enabled: true diff --git a/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-rs-external-access-multiple-horizons.yaml b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-rs-external-access-multiple-horizons.yaml new file mode 100644 index 000000000..69e418f9d --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-rs-external-access-multiple-horizons.yaml @@ -0,0 +1,28 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: test-tls-rs-external-access-multiple-horizons +spec: + members: 3 + version: 4.4.0 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + + persistent: false + security: + tls: + enabled: true + + connectivity: + replicaSetHorizons: + - "test-horizon-1": "mdb0-test-1.website.com:1337" + "test-horizon-2": "mdb0-test-2.website.com:2337" + - "test-horizon-1": "mdb1-test-1.website.com:1338" + "test-horizon-2": "mdb1-test-2.website.com:2338" + - "test-horizon-1": "mdb2-test-1.website.com:1339" + "test-horizon-2": "mdb2-test-2.website.com:2339" diff --git a/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-sc-additional-domains.yaml b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-sc-additional-domains.yaml new file mode 100644 index 000000000..eff1367e0 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-tls-sc-additional-domains.yaml @@ -0,0 +1,23 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: test-tls-sc-additional-domains +spec: + shardCount: 1 + mongodsPerShardCount: 1 + mongosCount: 2 + configServerCount: 1 + version: 4.4.0 + type: ShardedCluster + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + persistent: true + + security: + tls: + enabled: true + additionalCertificateDomains: + - "additional-cert-test.com" diff --git a/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-x509-all-options-rs.yaml b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-x509-all-options-rs.yaml new file mode 100644 index 000000000..0e9fa41f1 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-x509-all-options-rs.yaml @@ -0,0 +1,22 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: test-x509-all-options-rs +spec: + members: 3 + version: 4.4.0 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + persistent: false + security: + tls: + enabled: true + authentication: + internalCluster: X509 + enabled: true + modes: ["X509"] diff --git a/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-x509-all-options-sc.yaml b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-x509-all-options-sc.yaml new file mode 100644 index 000000000..909cb6223 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-x509-all-options-sc.yaml @@ -0,0 +1,25 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: test-x509-all-options-sc +spec: + shardCount: 1 + mongodsPerShardCount: 3 + mongosCount: 2 + configServerCount: 3 + version: 4.4.0 + type: ShardedCluster + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + persistent: false + security: + tls: + enabled: true + authentication: + internalCluster: X509 + enabled: true + modes: ["X509"] diff --git a/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-x509-rs.yaml b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-x509-rs.yaml new file mode 100644 index 000000000..9dfa126bf --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-x509-rs.yaml @@ -0,0 +1,22 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: test-x509-rs +spec: + members: 3 + version: 4.4.0 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + + persistent: false + security: + tls: + enabled: true + authentication: + enabled: true + modes: ["X509"] diff --git a/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-x509-sc-custom-ca.yaml b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-x509-sc-custom-ca.yaml new file mode 100644 index 000000000..2bbc0fae4 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-x509-sc-custom-ca.yaml @@ -0,0 +1,25 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: test-x509-sc-custom-ca +spec: + shardCount: 1 + mongodsPerShardCount: 3 + mongosCount: 2 + configServerCount: 3 + version: 4.4.0 + type: ShardedCluster + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + persistent: false + security: + clusterAuthenticationMode: x509 + tls: + enabled: true + authentication: + enabled: true + modes: ["X509"] diff --git a/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-x509-sc.yaml b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-x509-sc.yaml new file mode 100644 index 000000000..c45147c37 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-x509-sc.yaml @@ -0,0 +1,24 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: test-x509-sc +spec: + shardCount: 1 + mongodsPerShardCount: 3 + mongosCount: 2 + configServerCount: 3 + version: 4.4.0 + type: ShardedCluster + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + persistent: false + security: + tls: + enabled: true + authentication: + enabled: true + modes: ["X509"] diff --git a/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-x509-user.yaml b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-x509-user.yaml new file mode 100644 index 000000000..3fd629983 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/fixtures/test-x509-user.yaml @@ -0,0 +1,19 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDBUser +metadata: + name: test-x509-user +spec: + username: 'CN=x509-testing-user' + db: '$external' + mongodbResourceRef: + name: 'test-tls-base-rs-x509' + roles: + - db: "admin" + name: "clusterAdmin" + - db: "admin" + name: "userAdminAnyDatabase" + - db: "admin" + name: "readWrite" + - db: "admin" + name: "userAdminAnyDatabase" diff --git a/docker/mongodb-enterprise-tests/tests/tls/fixtures/x509-testing-user.csr b/docker/mongodb-enterprise-tests/tests/tls/fixtures/x509-testing-user.csr new file mode 100644 index 000000000..cde042567 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/fixtures/x509-testing-user.csr @@ -0,0 +1,7 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIHWMH4CAQAwHDEaMBgGA1UEAxMReDUwOS10ZXN0aW5nLXVzZXIwWTATBgcqhkjO +PQIBBggqhkjOPQMBBwNCAAQoqMAua1z39lipOfsAMCql2IQNabBN9I8C9BIdYdNF +GV595K1UuXZc5VP2LLWOGB0jje42pzYChDDOZbQbP5T6oAAwCgYIKoZIzj0EAwID +SAAwRQIhAPWTGJDQhZu2RgciBYdwP49xpqtQSdWv89Lrqjf2GIElAiBU63o478xt +MmPnkC2ALbKeVY8xXbIIr1pgXikYFQsg0g== +-----END CERTIFICATE REQUEST----- diff --git a/docker/mongodb-enterprise-tests/tests/tls/tls_allowssl.py b/docker/mongodb-enterprise-tests/tests/tls/tls_allowssl.py new file mode 100644 index 000000000..89e6a8656 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/tls_allowssl.py @@ -0,0 +1,47 @@ +import pytest + +from kubetester.kubetester import KubernetesTester, skip_if_local +from kubernetes import client +from kubetester.mongotester import ReplicaSetTester +from kubetester.kubetester import fixture as load_fixture +from kubetester.mongodb import MongoDB, Phase +from kubetester.certs import ( + ISSUER_CA_NAME, + create_mongodb_tls_certs, + create_agent_tls_certs, +) + +MDB_RESOURCE = "test-tls-base-rs-allow-ssl" + + +@pytest.fixture(scope="module") +def server_certs(issuer: str, namespace: str): + return create_mongodb_tls_certs( + ISSUER_CA_NAME, namespace, MDB_RESOURCE, f"{MDB_RESOURCE}-cert" + ) + + +@pytest.fixture(scope="module") +def mdb(namespace: str, server_certs: str, issuer_ca_configmap: str) -> MongoDB: + res = MongoDB.from_yaml( + load_fixture("test-tls-base-rs-allow-ssl.yaml"), namespace=namespace + ) + res["spec"]["security"]["tls"]["ca"] = issuer_ca_configmap + return res.create() + + +@pytest.mark.e2e_replica_set_tls_allow +def test_replica_set_running(mdb: MongoDB): + mdb.assert_reaches_phase(Phase.Running, timeout=400) + + +@pytest.mark.e2e_replica_set_tls_allow +@skip_if_local() +def test_mdb_is_reachable_with_no_ssl(mdb: MongoDB): + mdb.tester(use_ssl=False).assert_connectivity() + + +@pytest.mark.e2e_replica_set_tls_allow +@skip_if_local() +def test_mdb_is_reachable_with_ssl(mdb: MongoDB, ca_path: str): + mdb.tester(use_ssl=True, ca_path=ca_path).assert_connectivity() diff --git a/docker/mongodb-enterprise-tests/tests/tls/tls_multiple_different_ssl_configs.py b/docker/mongodb-enterprise-tests/tests/tls/tls_multiple_different_ssl_configs.py new file mode 100644 index 000000000..91ca65986 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/tls_multiple_different_ssl_configs.py @@ -0,0 +1,101 @@ +import pytest + +from kubetester.kubetester import KubernetesTester, skip_if_local +from kubetester.mongotester import ReplicaSetTester + +mdb_resources = { + "ssl_enabled": "test-tls-base-rs-require-ssl", + "ssl_disabled": "test-no-tls-no-status", +} + + +def cert_names(namespace, members=3): + return [ + "{}-{}.{}".format(mdb_resources["ssl_enabled"], i, namespace) + for i in range(members) + ] + + +@pytest.mark.e2e_tls_multiple_different_ssl_configs +class TestMultipleCreation(KubernetesTester): + """ + name: 2 Replica Sets on same project with different SSL configurations + description: | + 2 MDB/ReplicaSet object will be created, one of them will be have TLS enabled (required) + and the other one will not. Both of them should work, and listen on respective SSL/non SSL endpoints. + create: + - file: test-tls-base-rs-require-ssl.yaml + wait_for_message: Not all certificates have been approved by Kubernetes CA + timeout: 120 + + - file: test-no-tls-no-status.yaml + wait_until: in_running_state + timeout: 120 + """ + + def test_mdb_tls_resource_status_is_correct(self): + mdb = self.customv1.get_namespaced_custom_object( + "mongodb.com", "v1", self.namespace, "mongodb", mdb_resources["ssl_enabled"] + ) + assert ( + mdb["status"]["message"] + == "Not all certificates have been approved by Kubernetes CA" + ) + + mdb = self.customv1.get_namespaced_custom_object( + "mongodb.com", + "v1", + self.namespace, + "mongodb", + mdb_resources["ssl_disabled"], + ) + assert mdb["status"]["phase"] == "Running" + + +@pytest.mark.e2e_tls_multiple_different_ssl_configs +class TestMultipleApproval(KubernetesTester): + """ + name: Approval of certificates + description: | + Approves the certificates in Kubernetes, the MongoDB resource should move to Successful state. + """ + + def setup(self): + [self.approve_certificate(cert) for cert in cert_names(self.namespace)] + + def test_noop(self): + assert True + + +@pytest.mark.e2e_tls_multiple_different_ssl_configs +class TestMultipleRunning0(KubernetesTester): + """ + name: Both MDB objects should be in running state. + wait: + - resource: test-tls-base-rs-require-ssl + until: in_running_state + timeout: 200 + - resource: test-no-tls-no-status + until: in_running_state + timeout: 60 + """ + + @skip_if_local() + def test_mdb_ssl_enabled_is_not_reachable_with_no_ssl(self): + mongo_tester = ReplicaSetTester(mdb_resources["ssl_enabled"], 3) + mongo_tester.assert_no_connection() + + @skip_if_local() + def test_mdb_ssl_enabled_is_reachable_with_ssl(self): + mongo_tester = ReplicaSetTester(mdb_resources["ssl_enabled"], 3, ssl=True) + mongo_tester.assert_connectivity() + + @skip_if_local() + def test_mdb_ssl_disabled_is_reachable_with_no_ssl(self): + mongo_tester = ReplicaSetTester(mdb_resources["ssl_disabled"], 3) + mongo_tester.assert_connectivity() + + @skip_if_local() + def test_mdb_ssl_disabled_is_not_reachable_with_ssl(self): + mongo_tester = ReplicaSetTester(mdb_resources["ssl_disabled"], 3, ssl=True) + mongo_tester.assert_no_connection() diff --git a/docker/mongodb-enterprise-tests/tests/tls/tls_no_status.py b/docker/mongodb-enterprise-tests/tests/tls/tls_no_status.py new file mode 100644 index 000000000..90cddb1d9 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/tls_no_status.py @@ -0,0 +1,38 @@ +import pytest + +from kubetester.kubetester import KubernetesTester + +MDB_RESOURCE = "test-no-tls-no-status" + + +@pytest.mark.e2e_standalone_no_tls_no_status_is_set +class TestStandaloneWithNoTLS(KubernetesTester): + """ + name: Standalone with no TLS should not have empty "additionalMongodConfig" attribute set. + create: + file: test-no-tls-no-status.yaml + wait_until: in_running_state + timeout: 240 + """ + + def test_mdb_resource_status_is_correct(self): + mdb = self.customv1.get_namespaced_custom_object( + "mongodb.com", "v1", self.namespace, "mongodb", MDB_RESOURCE + ) + + assert mdb["status"]["phase"] == "Running" + assert "additionalMongodConfig" not in mdb["spec"] + assert "security" not in mdb + + +@pytest.mark.e2e_standalone_no_tls_no_status_is_set +class TestStandaloneWithNoTLSDeletion(KubernetesTester): + """ + name: Standalone with no TLS Status should be removed + delete: + file: test-no-tls-no-status.yaml + wait_until: mongo_resource_deleted + """ + + def test_deletion(self): + assert True diff --git a/docker/mongodb-enterprise-tests/tests/tls/tls_permissions_default.py b/docker/mongodb-enterprise-tests/tests/tls/tls_permissions_default.py new file mode 100644 index 000000000..48e8e2a55 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/tls_permissions_default.py @@ -0,0 +1,51 @@ +from pytest import mark, fixture +from kubetester import find_fixture +from kubetester.mongodb import MongoDB, Phase +from kubetester.kubetester import KubernetesTester +from tests.opsmanager.om_ops_manager_https import create_mongodb_tls_certs + + +@fixture(scope="module") +def certs_secret_prefix(namespace: str, issuer: str): + create_mongodb_tls_certs( + issuer, namespace, "test-tls-base-rs", "certs-test-tls-base-rs-cert" + ) + return "certs" + + +@fixture(scope="module") +def replica_set( + issuer_ca_configmap: str, namespace: str, certs_secret_prefix +) -> MongoDB: + resource = MongoDB.from_yaml( + find_fixture("test-tls-base-rs.yaml"), namespace=namespace + ) + resource.configure_custom_tls(issuer_ca_configmap, certs_secret_prefix) + return resource.create() + + +@mark.e2e_replica_set_tls_default +def test_replica_set(replica_set: MongoDB): + + replica_set.assert_reaches_phase(Phase.Running, timeout=400) + + +@mark.e2e_replica_set_tls_default +def test_file_has_correct_permissions(namespace: str): + # We test that the permissions are as expected by executing the stat + # command on all the pem files in the secrets/certs directory + cmd = [ + "/bin/sh", + "-c", + 'stat -c "%a" /mongodb-automation/tls/..data/*', + ] + for i in range(3): + result = KubernetesTester.run_command_in_pod_container( + f"test-tls-base-rs-{i}", + namespace, + cmd, + ).splitlines() + for res in result: + assert ( + res == "640" + ) # stat has no option for decimal values, so we check for 640, which is the octal representation for 416 diff --git a/docker/mongodb-enterprise-tests/tests/tls/tls_permissions_override.py b/docker/mongodb-enterprise-tests/tests/tls/tls_permissions_override.py new file mode 100644 index 000000000..be1db2b44 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/tls_permissions_override.py @@ -0,0 +1,71 @@ +from pytest import mark, fixture +from kubetester import find_fixture +from kubetester.mongodb import MongoDB, Phase +from kubetester.omtester import get_rs_cert_names +from kubetester.kubetester import KubernetesTester +from tests.opsmanager.om_ops_manager_https import create_mongodb_tls_certs + + +@fixture(scope="module") +def certs_secret_prefix(namespace: str, issuer: str): + create_mongodb_tls_certs( + issuer, namespace, "test-tls-base-rs", "certs-test-tls-base-rs-cert" + ) + return "certs" + + +@fixture(scope="module") +def replica_set( + issuer_ca_configmap: str, namespace: str, certs_secret_prefix +) -> MongoDB: + resource = MongoDB.from_yaml( + find_fixture("test-tls-base-rs.yaml"), namespace=namespace + ) + + resource["spec"]["podSpec"] = { + "podTemplate": { + "spec": { + "volumes": [ + { + "name": "secret-certs", + "secret": { + "defaultMode": 420, # This is the decimal value corresponding to 0644 permissions, different from the default 0640 (416) + }, + } + ] + } + } + } + resource.configure_custom_tls(issuer_ca_configmap, certs_secret_prefix) + return resource.create() + + +@mark.e2e_replica_set_tls_override +def test_replica_set(replica_set: MongoDB, namespace: str): + + certs = get_rs_cert_names( + replica_set["metadata"]["name"], namespace, with_agent_certs=False + ) + + replica_set.assert_reaches_phase(Phase.Running, timeout=400) + + +@mark.e2e_replica_set_tls_override +def test_file_has_correct_permissions(replica_set: MongoDB, namespace: str): + # We test that the permissions are as expected by executing the stat + # command on all the pem files in the secrets/certs directory + cmd = [ + "/bin/sh", + "-c", + 'stat -c "%a" /mongodb-automation/tls/..data/*', + ] + for i in range(3): + result = KubernetesTester.run_command_in_pod_container( + f"test-tls-base-rs-{i}", + namespace, + cmd, + ).splitlines() + for res in result: + assert ( + res == "644" + ) # stat has no option for decimal values, so we check for 644, which is the octal representation for 420 diff --git a/docker/mongodb-enterprise-tests/tests/tls/tls_preferssl.py b/docker/mongodb-enterprise-tests/tests/tls/tls_preferssl.py new file mode 100644 index 000000000..ae2f11a1b --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/tls_preferssl.py @@ -0,0 +1,48 @@ +import pytest + +from kubetester.kubetester import KubernetesTester, skip_if_local +from kubernetes import client +from kubetester.mongotester import ReplicaSetTester +from kubetester.kubetester import fixture as load_fixture +from kubetester.mongodb import MongoDB, Phase +from kubetester.certs import ( + ISSUER_CA_NAME, + create_mongodb_tls_certs, + create_agent_tls_certs, +) + +MDB_RESOURCE = "test-tls-base-rs-prefer-ssl" + + +@pytest.fixture(scope="module") +def server_certs(issuer: str, namespace: str): + return create_mongodb_tls_certs( + ISSUER_CA_NAME, namespace, MDB_RESOURCE, f"{MDB_RESOURCE}-cert" + ) + + +@pytest.fixture(scope="module") +def mdb(namespace: str, server_certs: str, issuer_ca_configmap: str) -> MongoDB: + res = MongoDB.from_yaml( + load_fixture("test-tls-base-rs-prefer-ssl.yaml"), namespace=namespace + ) + + res["spec"]["security"]["tls"]["ca"] = issuer_ca_configmap + return res.create() + + +@pytest.mark.e2e_replica_set_tls_prefer +def test_replica_set_running(mdb: MongoDB): + mdb.assert_reaches_phase(Phase.Running, timeout=400) + + +@pytest.mark.e2e_replica_set_tls_prefer +@skip_if_local() +def test_mdb_is_reachable_with_no_ssl(mdb: MongoDB): + mdb.tester(use_ssl=False).assert_connectivity() + + +@pytest.mark.e2e_replica_set_tls_prefer +@skip_if_local() +def test_mdb_is_reachable_with_ssl(mdb: MongoDB, ca_path: str): + mdb.tester(use_ssl=True, ca_path=ca_path).assert_connectivity() diff --git a/docker/mongodb-enterprise-tests/tests/tls/tls_replica_set_process_hostnames.py b/docker/mongodb-enterprise-tests/tests/tls/tls_replica_set_process_hostnames.py new file mode 100644 index 000000000..f0f61d09a --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/tls_replica_set_process_hostnames.py @@ -0,0 +1,112 @@ +# This test currently relies on MetalLB IP assignment that is configured for kind in scripts/dev/recreate_kind_cluster.sh +# Each service of type LoadBalancer will get IP starting from 172.18.255.200 +# scripts/dev/coredns_single_cluster.yaml configures that my-replica-set-0.mongodb.interconnected starts at 172.18.255.200 + +# Other e2e tests checking externalDomain (consider updating all of them when changing test logic here): +# tls/tls_replica_set_process_hostnames.py +# replicaset/replica_set_process_hostnames.py +# om_ops_manager_backup_tls_custom_ca.py + + +from typing import List + +import pytest +from pytest import fixture + +from kubetester import ( + create_or_update, + try_load, +) +from kubetester.certs import ( + ISSUER_CA_NAME, + create_mongodb_tls_certs, +) +from kubetester.kubetester import ( + fixture as yaml_fixture, +) +from kubetester.mongodb import MongoDB, Phase +from tests.conftest import ( + default_external_domain, + external_domain_fqdns, + update_coredns_hosts, +) + + +@fixture(scope="module") +def replica_set_name() -> str: + return "my-replica-set" + + +@fixture(scope="module") +def replica_set_members() -> int: + return 3 + + +@pytest.fixture(scope="module") +def server_certs(issuer: str, namespace: str, replica_set_members: int, replica_set_name: str): + """ + Issues certificate containing only custom_service_fqdns in SANs + """ + return create_mongodb_tls_certs( + ISSUER_CA_NAME, + namespace, + replica_set_name, + f"{replica_set_name}-cert", + process_hostnames=external_domain_fqdns(replica_set_name, replica_set_members), + ) + + +@fixture(scope="function") +def replica_set( + namespace: str, + replica_set_name: str, + replica_set_members: int, + custom_mdb_version: str, + server_certs: str, + issuer_ca_configmap: str, +) -> MongoDB: + resource = MongoDB.from_yaml(yaml_fixture("test-tls-base-rs.yaml"), replica_set_name, namespace) + try_load(resource) + + resource["spec"]["members"] = replica_set_members + resource["spec"]["externalAccess"] = {} + resource["spec"]["externalAccess"]["externalDomain"] = default_external_domain() + resource["spec"]["security"]["tls"]["ca"] = issuer_ca_configmap + resource.set_version(custom_mdb_version) + + return resource + + +@pytest.mark.e2e_replica_set_tls_process_hostnames +def test_update_coredns(): + hosts = [ + ("172.18.255.200", "my-replica-set-0.mongodb.interconnected"), + ("172.18.255.201", "my-replica-set-1.mongodb.interconnected"), + ("172.18.255.202", "my-replica-set-2.mongodb.interconnected"), + ("172.18.255.203", "my-replica-set-3.mongodb.interconnected"), + ] + + update_coredns_hosts(hosts) + + +@pytest.mark.e2e_replica_set_tls_process_hostnames +def test_create_replica_set(replica_set: MongoDB): + create_or_update(replica_set) + + +@pytest.mark.e2e_replica_set_tls_process_hostnames +def test_replica_set_in_running_state(replica_set: MongoDB): + replica_set.assert_reaches_phase(Phase.Running, timeout=1000) + + +@pytest.mark.e2e_replica_set_tls_process_hostnames +def test_automation_config_contains_external_domains_in_hostnames(replica_set: MongoDB): + processes = replica_set.get_automation_config_tester().get_replica_set_processes(replica_set.name) + hostnames = [process["hostname"] for process in processes] + assert hostnames == external_domain_fqdns(replica_set.name, replica_set.get_members()) + + +@pytest.mark.e2e_replica_set_tls_process_hostnames +def test_connectivity(replica_set: MongoDB, ca_path: str): + tester = replica_set.tester(ca_path=ca_path) + tester.assert_connectivity() diff --git a/docker/mongodb-enterprise-tests/tests/tls/tls_replicaset_certsSecretPrefix.py b/docker/mongodb-enterprise-tests/tests/tls/tls_replicaset_certsSecretPrefix.py new file mode 100644 index 000000000..8a44d0291 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/tls_replicaset_certsSecretPrefix.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 + +import pytest + +from kubetester.kubetester import skip_if_local +from kubetester.kubetester import fixture as load_fixture +from kubetester.mongodb import MongoDB, Phase +from kubetester.certs import ( + ISSUER_CA_NAME, + create_mongodb_tls_certs, +) + +MDB_RESOURCE = "test-tls-base-rs-require-ssl" + + +@pytest.fixture(scope="module") +def server_certs(issuer: str, namespace: str): + return create_mongodb_tls_certs( + ISSUER_CA_NAME, + namespace, + MDB_RESOURCE, + f"prefix-{MDB_RESOURCE}-cert", + replicas=3, + ) + + +@pytest.fixture(scope="module") +def mdb(namespace: str, server_certs: str, issuer_ca_configmap: str) -> MongoDB: + res = MongoDB.from_yaml( + load_fixture("test-tls-base-rs-require-ssl.yaml"), namespace=namespace + ) + + res["spec"]["security"]["tls"] = {"ca": issuer_ca_configmap} + # Setting security.certsSecretPrefix implicitly enables TLS + res["spec"]["security"]["certsSecretPrefix"] = "prefix" + return res.create() + + +@pytest.mark.e2e_replica_set_tls_certs_secret_prefix +def test_replica_set_running(mdb: MongoDB): + mdb.assert_reaches_phase(Phase.Running, timeout=400) + + +@pytest.mark.e2e_replica_set_tls_certs_secret_prefix +@skip_if_local() +def test_mdb_is_not_reachable_with_no_ssl(mdb: MongoDB): + mdb.tester(use_ssl=False).assert_no_connection() + + +@pytest.mark.e2e_replica_set_tls_certs_secret_prefix +@skip_if_local() +def test_mdb_is_reachable_with_ssl(mdb: MongoDB, ca_path: str): + mdb.tester(use_ssl=True, ca_path=ca_path).assert_connectivity() diff --git a/docker/mongodb-enterprise-tests/tests/tls/tls_requiressl.py b/docker/mongodb-enterprise-tests/tests/tls/tls_requiressl.py new file mode 100644 index 000000000..f3f3b5faf --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/tls_requiressl.py @@ -0,0 +1,107 @@ +import pytest + +from kubetester.kubetester import skip_if_local +from kubetester.kubetester import fixture as load_fixture +from kubetester.mongodb import MongoDB, Phase +from kubetester.certs import ISSUER_CA_NAME, create_mongodb_tls_certs, Certificate + +MDB_RESOURCE = "test-tls-base-rs-require-ssl" + + +@pytest.fixture(scope="module") +def server_certs(issuer: str, namespace: str): + return create_mongodb_tls_certs( + ISSUER_CA_NAME, + namespace, + MDB_RESOURCE, + f"{MDB_RESOURCE}-cert", + replicas=5, + ) + + +@pytest.fixture(scope="module") +def mdb(namespace: str, server_certs: str, issuer_ca_configmap: str) -> MongoDB: + res = MongoDB.from_yaml( + load_fixture("test-tls-base-rs-require-ssl.yaml"), namespace=namespace + ) + + res["spec"]["security"]["tls"]["ca"] = issuer_ca_configmap + return res.create() + + +@pytest.mark.e2e_replica_set_tls_require +def test_replica_set_running(mdb: MongoDB): + mdb.assert_reaches_phase(Phase.Running, timeout=400) + + +@pytest.mark.e2e_replica_set_tls_require +@skip_if_local() +def test_mdb_is_reachable_with_no_ssl(mdb: MongoDB): + mdb.tester(use_ssl=False).assert_no_connection() + + +@pytest.mark.e2e_replica_set_tls_require +@skip_if_local() +def test_mdb_is_reachable_with_ssl(mdb: MongoDB, ca_path: str): + mdb.tester(use_ssl=True, ca_path=ca_path).assert_connectivity() + + +@pytest.mark.e2e_replica_set_tls_require +def test_scale_up_replica_set(mdb: MongoDB): + mdb.load() + mdb["spec"]["members"] = 5 + mdb.update() + mdb.assert_reaches_phase(Phase.Running, timeout=400) + + +@pytest.mark.e2e_replica_set_tls_require +@skip_if_local() +def test_mdb_scaled_up_is_not_reachable_with_no_ssl(mdb: MongoDB): + mdb.tester(use_ssl=False).assert_no_connection() + + +@pytest.mark.e2e_replica_set_tls_require +@skip_if_local() +def test_mdb_scaled_up_is_reachable_with_ssl(mdb: MongoDB, ca_path: str): + mdb.tester(use_ssl=True, ca_path=ca_path).assert_connectivity() + + +@pytest.mark.e2e_replica_set_tls_require +def test_scale_down_replica_set(mdb: MongoDB): + mdb.load() + mdb["spec"]["members"] = 3 + mdb.update() + mdb.assert_reaches_phase(Phase.Running, timeout=1000) + + +@pytest.mark.e2e_replica_set_tls_require +@skip_if_local() +def test_mdb_scaled_down_is_reachable_with_no_ssl(mdb: MongoDB): + mdb.tester(use_ssl=False).assert_no_connection() + + +@pytest.mark.e2e_replica_set_tls_require +@skip_if_local() +def test_mdb_scaled_down_is_reachable_with_ssl(mdb: MongoDB, ca_path: str): + mdb.tester(use_ssl=True, ca_path=ca_path).assert_connectivity() + + +@pytest.mark.e2e_replica_set_tls_require +def test_change_certificate_and_wait_for_running(mdb: MongoDB, namespace: str): + cert = Certificate(name=f"{MDB_RESOURCE}-cert", namespace=namespace).load() + cert["spec"]["dnsNames"].append("foo") + cert.update() + mdb.assert_abandons_phase(Phase.Running, timeout=60) + mdb.assert_reaches_phase(Phase.Running, timeout=600) + + +@pytest.mark.e2e_replica_set_tls_require +@skip_if_local() +def test_mdb_renewed_is_reachable_with_no_ssl(mdb: MongoDB): + mdb.tester(use_ssl=False).assert_no_connection() + + +@pytest.mark.e2e_replica_set_tls_require +@skip_if_local() +def test_mdb_renewed_is_reachable_with_ssl(mdb: MongoDB, ca_path: str): + mdb.tester(use_ssl=True, ca_path=ca_path).assert_connectivity() diff --git a/docker/mongodb-enterprise-tests/tests/tls/tls_requiressl_and_disable.py b/docker/mongodb-enterprise-tests/tests/tls/tls_requiressl_and_disable.py new file mode 100644 index 000000000..a5abc7451 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/tls_requiressl_and_disable.py @@ -0,0 +1,168 @@ +import pytest +from pytest import fixture + +from kubetester import MongoDB, delete_secret +from kubetester.kubetester import ( + KubernetesTester, + skip_if_local, + fixture as yaml_fixture, +) +from kubetester.mongodb import Phase +from kubetester.certs import ( + ISSUER_CA_NAME, + create_mongodb_tls_certs, + create_agent_tls_certs, +) + +MDB_RESOURCE_NAME = "tls-replica-set" + + +@pytest.fixture(scope="module") +def server_certs(issuer: str, namespace: str): + return create_mongodb_tls_certs( + ISSUER_CA_NAME, namespace, MDB_RESOURCE_NAME, f"prefix-{MDB_RESOURCE_NAME}-cert" + ) + + +@pytest.fixture(scope="module") +def tls_replica_set( + namespace: str, custom_mdb_version: str, issuer_ca_configmap: str, server_certs: str +) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("test-tls-base-rs-require-ssl.yaml"), MDB_RESOURCE_NAME, namespace + ) + + # TLS can be enabled implicitly by specifying security.certsSecretPrefix field + resource["spec"]["security"] = { + "certsSecretPrefix": "prefix", + "tls": {"ca": issuer_ca_configmap}, + } + resource.set_version(custom_mdb_version) + + yield resource.create() + + resource.delete() + + +@pytest.mark.e2e_replica_set_tls_require_and_disable +def test_replica_set_creation(tls_replica_set: MongoDB): + tls_replica_set.assert_reaches_phase(Phase.Running, timeout=300) + + +@pytest.mark.e2e_replica_set_tls_require_and_disable +@skip_if_local() +def test_replica_set_is_not_reachable_without_tls(tls_replica_set: MongoDB): + tester = tls_replica_set.tester(use_ssl=False) + tester.assert_no_connection() + + +@pytest.mark.e2e_replica_set_tls_require_and_disable +@skip_if_local() +def test_replica_set_is_reachable_with_tls(tls_replica_set: MongoDB, ca_path: str): + tester = tls_replica_set.tester(use_ssl=True, ca_path=ca_path) + tester.assert_connectivity() + + +@pytest.mark.e2e_replica_set_tls_require_and_disable +def test_configure_prefer_ssl(tls_replica_set: MongoDB): + """ + Change ssl configuration to preferSSL + """ + tls_replica_set["spec"]["additionalMongodConfig"] = { + "net": {"ssl": {"mode": "preferSSL"}} + } + + tls_replica_set.update() + tls_replica_set.assert_abandons_phase(Phase.Running) + tls_replica_set.assert_reaches_phase(Phase.Running, timeout=600) + + +@pytest.mark.e2e_replica_set_tls_require_and_disable +@skip_if_local() +def test_replica_set_is_reachable_without_ssl_prefer_ssl(tls_replica_set: MongoDB): + tester = tls_replica_set.tester(use_ssl=False) + tester.assert_connectivity() + + +@pytest.mark.e2e_replica_set_tls_require_and_disable +@skip_if_local() +def test_replica_set_is_reachable_with_ssl_prefer_ssl( + tls_replica_set: MongoDB, ca_path: str +): + tester = tls_replica_set.tester(use_ssl=True, ca_path=ca_path) + tester.assert_connectivity() + + +@pytest.mark.e2e_replica_set_tls_require_and_disable +def test_configure_allow_ssl(tls_replica_set: MongoDB): + """ + Change ssl configuration to allowSSL + """ + tls_replica_set["spec"]["additionalMongodConfig"] = { + "net": {"ssl": {"mode": "allowSSL"}} + } + + tls_replica_set.update() + tls_replica_set.assert_abandons_phase(Phase.Running) + tls_replica_set.assert_reaches_phase(Phase.Running, timeout=600) + + +@pytest.mark.e2e_replica_set_tls_require_and_disable +@skip_if_local() +def test_replica_set_is_reachable_without_tls_allow_ssl(tls_replica_set: MongoDB): + tester = tls_replica_set.tester(use_ssl=False) + tester.assert_connectivity() + + +@pytest.mark.e2e_replica_set_tls_require_and_disable +@skip_if_local() +def test_replica_set_is_reachable_with_tls_allow_ssl( + tls_replica_set: MongoDB, ca_path: str +): + tester = tls_replica_set.tester(use_ssl=True, ca_path=ca_path) + tester.assert_connectivity() + + +@pytest.mark.e2e_replica_set_tls_require_and_disable +def test_disabled_ssl(tls_replica_set: MongoDB): + """ + Disable ssl explicitly + """ + tls_replica_set.load() + + # TLS can be disabled explicitly by setting security.tls.enabled to false and having + # no configuration for certificate secret + tls_replica_set["spec"]["security"]["certsSecretPrefix"] = None + tls_replica_set["spec"]["security"]["tls"] = {"enabled": False} + + tls_replica_set.update() + tls_replica_set.assert_abandons_phase(Phase.Running) + tls_replica_set.assert_reaches_phase(Phase.Running, timeout=600) + + +@pytest.mark.e2e_replica_set_tls_require_and_disable +@skip_if_local() +def test_replica_set_is_reachable_with_tls_disabled(tls_replica_set: MongoDB): + tester = tls_replica_set.tester(use_ssl=False) + tester.assert_connectivity() + + +@pytest.mark.e2e_replica_set_tls_require_and_disable +@skip_if_local() +def test_replica_set_is_not_reachable_over_ssl_with_ssl_disabled( + tls_replica_set: MongoDB, ca_path: str +): + tester = tls_replica_set.tester(use_ssl=True, ca_path=ca_path) + tester.assert_no_connection() + + +@pytest.mark.e2e_replica_set_tls_require_and_disable +@pytest.mark.xfail( + reason="Changing the TLS secret should not cause reconciliations after TLS is disabled" +) +def test_changes_to_secret_do_not_cause_reconciliation( + tls_replica_set: MongoDB, namespace: str +): + + delete_secret(namespace, f"{MDB_RESOURCE_NAME}-cert") + tls_replica_set.assert_abandons_phase(Phase.Running, timeout=60) diff --git a/docker/mongodb-enterprise-tests/tests/tls/tls_requiressl_to_allow.py b/docker/mongodb-enterprise-tests/tests/tls/tls_requiressl_to_allow.py new file mode 100644 index 000000000..88434e4e3 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/tls_requiressl_to_allow.py @@ -0,0 +1,101 @@ +import pytest +from pytest import fixture + +from kubetester import MongoDB +from kubetester.certs import create_mongodb_tls_certs +from kubetester.kubetester import skip_if_local, fixture as yaml_fixture +from kubetester.mongodb import Phase + +MDB_RESOURCE = "test-tls-base-rs-require-ssl" + + +@fixture("module") +def rs_certs_secret(namespace: str, issuer: str): + create_mongodb_tls_certs( + issuer, namespace, MDB_RESOURCE, "certs-test-tls-base-rs-require-ssl-cert" + ) + return "certs" + + +@fixture(scope="module") +def tls_replica_set( + namespace: str, + custom_mdb_version: str, + issuer_ca_configmap: str, + rs_certs_secret: str, +) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("test-tls-base-rs-require-ssl.yaml"), + name=MDB_RESOURCE, + namespace=namespace, + ) + + resource.set_version(custom_mdb_version) + # no TLS to start with + resource["spec"]["security"] = {} + + yield resource.create() + + resource.delete() + + +@pytest.mark.e2e_replica_set_tls_require_to_allow +def test_replica_set_creation(tls_replica_set: MongoDB): + tls_replica_set.assert_reaches_phase(Phase.Running, timeout=300) + + +@pytest.mark.e2e_replica_set_tls_require_to_allow +def test_enable_tls( + tls_replica_set: MongoDB, issuer_ca_configmap: str, rs_certs_secret: str +): + tls_replica_set.configure_custom_tls( + issuer_ca_configmap_name=issuer_ca_configmap, + tls_cert_secret_name=rs_certs_secret, + ) + tls_replica_set.update() + tls_replica_set.assert_abandons_phase(Phase.Running, timeout=60) + tls_replica_set.assert_reaches_phase(Phase.Running, timeout=300) + + +@pytest.mark.e2e_replica_set_tls_require_to_allow +@skip_if_local() +def test_replica_set_is_not_reachable_without_tls(tls_replica_set: MongoDB): + tester = tls_replica_set.tester(use_ssl=False) + tester.assert_no_connection() + + +@pytest.mark.e2e_replica_set_tls_require_to_allow +@skip_if_local() +def test_replica_set_is_reachable_with_tls(tls_replica_set: MongoDB, ca_path: str): + tester = tls_replica_set.tester(use_ssl=True, ca_path=ca_path) + tester.assert_connectivity() + + +@pytest.mark.e2e_replica_set_tls_require_to_allow +def test_configure_allow_ssl(tls_replica_set: MongoDB): + """ + Change ssl configuration to allowSSL + """ + tls_replica_set["spec"]["additionalMongodConfig"] = { + "net": {"ssl": {"mode": "allowSSL"}} + } + + tls_replica_set.update() + tls_replica_set.assert_abandons_phase(Phase.Running) + tls_replica_set.assert_reaches_phase(Phase.Running, timeout=300) + + +@pytest.mark.e2e_replica_set_tls_require_to_allow +@skip_if_local() +def test_replica_set_is_reachable_without_tls_allow_ssl(tls_replica_set: MongoDB): + tester = tls_replica_set.tester(use_ssl=False) + tester.assert_connectivity() + + +@pytest.mark.e2e_replica_set_tls_require_to_allow +@skip_if_local() +def test_replica_set_is_reachable_with_tls_allow_ssl( + tls_replica_set: MongoDB, ca_path: str +): + tester = tls_replica_set.tester(use_ssl=True, ca_path=ca_path) + tester.assert_connectivity() diff --git a/docker/mongodb-enterprise-tests/tests/tls/tls_requiressl_upgrade.py b/docker/mongodb-enterprise-tests/tests/tls/tls_requiressl_upgrade.py new file mode 100644 index 000000000..2076e376e --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/tls_requiressl_upgrade.py @@ -0,0 +1,69 @@ +import pytest +from kubernetes import client +from kubetester.kubetester import KubernetesTester, skip_if_local +from kubetester.mongotester import ReplicaSetTester +from kubetester.kubetester import fixture as load_fixture +from kubetester.mongodb import MongoDB, Phase +from kubetester.certs import ( + ISSUER_CA_NAME, + create_mongodb_tls_certs, + create_agent_tls_certs, +) + +MDB_RESOURCE = "test-tls-upgrade" + + +@pytest.fixture(scope="module") +def server_certs(issuer: str, namespace: str): + return create_mongodb_tls_certs( + ISSUER_CA_NAME, namespace, MDB_RESOURCE, f"{MDB_RESOURCE}-cert" + ) + + +@pytest.fixture(scope="module") +def mdb(namespace: str, server_certs: str, issuer_ca_configmap: str) -> MongoDB: + res = MongoDB.from_yaml( + load_fixture("test-tls-base-rs-require-ssl-upgrade.yaml"), namespace=namespace + ) + res["spec"]["security"] = {"tls": {"ca": issuer_ca_configmap}} + return res.create() + + +@pytest.mark.e2e_replica_set_tls_require_upgrade +def test_replica_set_running(mdb: MongoDB): + mdb.assert_reaches_phase(Phase.Running, timeout=400) + + +@pytest.mark.e2e_replica_set_tls_require_upgrade +def test_mdb_is_reachable_with_no_ssl(mdb: MongoDB): + mdb.tester(use_ssl=False).assert_connectivity() + + +@pytest.mark.e2e_replica_set_tls_require_upgrade +def test_enables_TLS_replica_set( + mdb: MongoDB, server_certs: str, issuer_ca_configmap: str +): + mdb.load() + mdb["spec"]["security"] = {"tls": {"enabled": True}, "ca": issuer_ca_configmap} + mdb.update() + mdb.assert_reaches_phase(Phase.Running, timeout=400) + + +@pytest.mark.e2e_replica_set_tls_require_upgrade +def test_require_TLS(mdb: MongoDB): + mdb.load() + mdb["spec"]["additionalMongodConfig"]["net"]["ssl"]["mode"] = "requireSSL" + mdb.update() + mdb.assert_reaches_phase(Phase.Running, timeout=400) + + +@pytest.mark.e2e_replica_set_tls_require_upgrade +@skip_if_local() +def test_mdb_is_not_reachable_with_no_ssl(): + ReplicaSetTester(MDB_RESOURCE, 3).assert_no_connection() + + +@pytest.mark.e2e_replica_set_tls_require_upgrade +@skip_if_local() +def test_mdb_is_reachable_with_ssl(mdb: MongoDB, ca_path: str): + mdb.tester(use_ssl=True, ca_path=ca_path).assert_connectivity() diff --git a/docker/mongodb-enterprise-tests/tests/tls/tls_rs_additional_certs.py b/docker/mongodb-enterprise-tests/tests/tls/tls_rs_additional_certs.py new file mode 100644 index 000000000..b13d39f4f --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/tls_rs_additional_certs.py @@ -0,0 +1,91 @@ +import re +import pytest +from kubetester.omtester import get_rs_cert_names +from kubetester.kubetester import KubernetesTester, skip_if_local +from kubetester.mongotester import ReplicaSetTester +from kubetester.kubetester import fixture as load_fixture +from kubetester.mongodb import MongoDB, Phase +from kubetester.certs import ( + ISSUER_CA_NAME, + create_mongodb_tls_certs, + create_agent_tls_certs, +) +import time +import jsonpatch + +from kubernetes import client + +MDB_RESOURCE_NAME = "test-tls-additional-domains" + + +@pytest.fixture(scope="module") +def server_certs(issuer: str, namespace: str): + return create_mongodb_tls_certs( + ISSUER_CA_NAME, + namespace, + MDB_RESOURCE_NAME, + f"{MDB_RESOURCE_NAME}-cert", + additional_domains=[ + "test-tls-additional-domains-0.additional-cert-test.com", + "test-tls-additional-domains-1.additional-cert-test.com", + "test-tls-additional-domains-2.additional-cert-test.com", + ], + ) + + +@pytest.fixture(scope="module") +def mdb(namespace: str, server_certs: str, issuer_ca_configmap: str) -> MongoDB: + res = MongoDB.from_yaml( + load_fixture("test-tls-additional-domains.yaml"), namespace=namespace + ) + res["spec"]["security"]["tls"]["ca"] = issuer_ca_configmap + return res.create() + + +@pytest.mark.e2e_tls_rs_additional_certs +class TestReplicaSetWithAdditionalCertDomains(KubernetesTester): + def test_replica_set_is_running(self, mdb: MongoDB): + mdb.assert_reaches_phase(Phase.Running, timeout=400) + + @skip_if_local + def test_can_still_connect(self, mdb: MongoDB, ca_path: str): + tester = mdb.tester(use_ssl=True, ca_path=ca_path) + tester.assert_connectivity() + + +@pytest.mark.e2e_tls_rs_additional_certs +class TestReplicaSetRemoveAdditionalCertDomains(KubernetesTester): + def test_remove_additional_certs(self, namespace: str, mdb: MongoDB): + # We don't have a nice way to delete fields from a resource specification + # in our test env, so we need to achieve it with specific uses of patches + body = { + "op": "remove", + "path": "/spec/security/tls/additionalCertificateDomains", + } + patch = jsonpatch.JsonPatch([body]) + + last_transition = mdb.get_status_last_transition_time() + + mdb_resource = client.CustomObjectsApi().get_namespaced_custom_object( + mdb.group, + mdb.version, + namespace, + mdb.plural, + mdb.name, + ) + client.CustomObjectsApi().replace_namespaced_custom_object( + mdb.group, + mdb.version, + namespace, + mdb.plural, + mdb.name, + jsonpatch.apply_patch(mdb_resource, patch), + ) + + mdb.assert_state_transition_happens(last_transition) + mdb.assert_reaches_phase(Phase.Running, timeout=400) + + @skip_if_local + def test_can_still_connect(self, mdb: MongoDB, ca_path: str): + tester = mdb.tester(use_ssl=True, ca_path=ca_path) + tester.assert_connectivity() diff --git a/docker/mongodb-enterprise-tests/tests/tls/tls_rs_external_access.py b/docker/mongodb-enterprise-tests/tests/tls/tls_rs_external_access.py new file mode 100644 index 000000000..5306b8e21 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/tls_rs_external_access.py @@ -0,0 +1,200 @@ +import re + +import pytest + +from kubetester.omtester import get_rs_cert_names +from kubetester.kubetester import KubernetesTester, skip_if_local +from kubetester.mongotester import ReplicaSetTester +from kubetester.mongodb import MongoDB, Phase + +from kubetester.kubetester import fixture as load_fixture +from kubetester import create_secret, find_fixture, create_or_update +from kubetester.certs import ( + Certificate, + ISSUER_CA_NAME, + create_mongodb_tls_certs, + create_agent_tls_certs, +) + + +@pytest.fixture(scope="module") +def server_certs(issuer: str, namespace: str): + mdb_resource = "test-tls-base-rs-external-access" + return create_mongodb_tls_certs( + ISSUER_CA_NAME, + namespace, + mdb_resource, + f"{mdb_resource}-cert", + replicas=4, + additional_domains=[ + "*.website.com", + ], + ) + + +@pytest.fixture(scope="module") +def server_certs_multiple_horizons(issuer: str, namespace: str): + mdb_resource = "test-tls-rs-external-access-multiple-horizons" + return create_mongodb_tls_certs( + ISSUER_CA_NAME, + namespace, + mdb_resource, + f"{mdb_resource}-cert", + replicas=4, + additional_domains=[ + "mdb0-test-1.website.com", + "mdb0-test-2.website.com", + "mdb1-test-1.website.com", + "mdb1-test-2.website.com", + "mdb2-test-1.website.com", + "mdb2-test-2.website.com", + ], + ) + + +@pytest.fixture(scope="module") +def mdb(namespace: str, server_certs: str, issuer_ca_configmap: str) -> MongoDB: + res = MongoDB.from_yaml( + load_fixture("test-tls-base-rs-external-access.yaml"), namespace=namespace + ) + res["spec"]["security"]["tls"]["ca"] = issuer_ca_configmap + return create_or_update(res) + + +@pytest.fixture(scope="module") +def mdb_multiple_horizons( + namespace: str, server_certs_multiple_horizons: str, issuer_ca_configmap: str +) -> MongoDB: + res = MongoDB.from_yaml( + load_fixture("test-tls-rs-external-access-multiple-horizons.yaml"), + namespace=namespace, + ) + res["spec"]["security"]["tls"]["ca"] = issuer_ca_configmap + return res.create() + + +@pytest.mark.e2e_tls_rs_external_access +class TestReplicaSetWithExternalAccess(KubernetesTester): + def test_replica_set_running(self, mdb: MongoDB): + mdb.assert_reaches_phase(Phase.Running, timeout=240) + + @skip_if_local + def test_can_still_connect(self, mdb: MongoDB, ca_path: str): + tester = mdb.tester(use_ssl=True, ca_path=ca_path) + tester.assert_connectivity() + + def test_automation_config_is_right(self): + ac = self.get_automation_config() + members = ac["replicaSets"][0]["members"] + horizon_names = [m["horizons"] for m in members] + assert horizon_names == [ + {"test-horizon": "mdb0-test.website.com:1337"}, + {"test-horizon": "mdb1-test.website.com:1337"}, + {"test-horizon": "mdb2-test.website.com:1337"}, + ] + + @skip_if_local + def test_has_right_certs(self): + """ + Check that mongod processes behind the replica set service are + serving the right certificates. + """ + host = f"test-tls-base-rs-external-access-svc.{self.namespace}.svc" + assert any( + san.endswith(".website.com") for san in self.get_mongo_server_sans(host) + ) + + +@pytest.mark.e2e_tls_rs_external_access +def test_scale_up_replica_set(mdb: MongoDB): + mdb.load() + mdb["spec"]["members"] = 4 + mdb["spec"]["connectivity"]["replicaSetHorizons"] = [ + {"test-horizon": "mdb0-test.website.com:1337"}, + {"test-horizon": "mdb1-test.website.com:1337"}, + {"test-horizon": "mdb2-test.website.com:1337"}, + {"test-horizon": "mdb3-test.website.com:1337"}, + ] + mdb.update() + mdb.assert_reaches_phase(Phase.Running, timeout=240) + + +@pytest.mark.e2e_tls_rs_external_access +def tests_invalid_cert(mdb: MongoDB): + mdb.load() + mdb["spec"]["connectivity"]["replicaSetHorizons"] = [ + {"test-horizon": "mdb0-test.website.com:1337"}, + {"test-horizon": "mdb1-test.website.com:1337"}, + {"test-horizon": "mdb2-test.website.com:1337"}, + {"test-horizon": "mdb5-test.wrongwebsite.com:1337"}, + ] + mdb.update() + mdb.assert_reaches_phase( + Phase.Failed, + timeout=240, + ) + + +@pytest.mark.e2e_tls_rs_external_access +class TestReplicaSetCanRemoveExternalAccess(KubernetesTester): + """ + update: + file: test-tls-base-rs-external-access.yaml + wait_until: in_running_state + patch: '[{"op":"replace","path":"/spec/connectivity/replicaSetHorizons", "value": []}]' + timeout: 240 + """ + + def test_can_remove_horizons(self): + return True + + +@pytest.mark.e2e_tls_rs_external_access +class TestReplicaSetWithNoTLSDeletion(KubernetesTester): + """ + delete: + file: test-tls-base-rs-external-access.yaml + wait_until: mongo_resource_deleted_no_om + timeout: 240 + """ + + def test_deletion(self): + assert True + + +@pytest.mark.e2e_tls_rs_external_access +class TestReplicaSetWithMultipleHorizons(KubernetesTester): + def test_replica_set_running(self, mdb_multiple_horizons: MongoDB): + mdb_multiple_horizons.assert_reaches_phase(Phase.Running, timeout=240) + + def test_automation_config_is_right(self): + ac = self.get_automation_config() + members = ac["replicaSets"][0]["members"] + horizons = [m["horizons"] for m in members] + assert horizons == [ + { + "test-horizon-1": "mdb0-test-1.website.com:1337", + "test-horizon-2": "mdb0-test-2.website.com:2337", + }, + { + "test-horizon-1": "mdb1-test-1.website.com:1338", + "test-horizon-2": "mdb1-test-2.website.com:2338", + }, + { + "test-horizon-1": "mdb2-test-1.website.com:1339", + "test-horizon-2": "mdb2-test-2.website.com:2339", + }, + ] + + +@pytest.mark.e2e_tls_rs_external_access +class TestReplicaSetDeleteMultipleHorizon(KubernetesTester): + """ + delete: + file: test-tls-rs-external-access-multiple-horizons.yaml + wait_until: mongo_resource_deleted_no_om + timeout: 240 + """ + + def test_deletion(self): + assert True diff --git a/docker/mongodb-enterprise-tests/tests/tls/tls_rs_external_access_manual_connectivity.py b/docker/mongodb-enterprise-tests/tests/tls/tls_rs_external_access_manual_connectivity.py new file mode 100644 index 000000000..9eb09ee69 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/tls_rs_external_access_manual_connectivity.py @@ -0,0 +1,121 @@ +from typing import List + +import pytest +import yaml + +from kubetester.kubetester import KubernetesTester +from kubetester.mongodb import MongoDB, Phase +from kubetester.kubetester import fixture as load_fixture +from kubetester.certs import ( + ISSUER_CA_NAME, + create_mongodb_tls_certs, +) + + +# This test will set up an environment which will configure a resource with split horizon enabled. + +# Steps to run this test. + +# 1. Change the following variable to any worker node in your cluster. (describe output of node) +WORKER_NODE_HOSTNAME = "ec2-18-222-120-63.us-east-2.compute.amazonaws.com" + +# 2. Run the test `make e2e test=e2e_tls_rs_external_access_manual_connectivity` + +# 3. Wait for the test to pass (this means the environment is set up.) + +# 4. Exec into any database pod and note the contents of the files referenced by the fields +# * net.tls.certificateKeyFile +# * net.tlsCAFile +# from the /data/automation-mongod.conf file. + +# 5. Test the connection +# Testing the connection can be done from either the worker node or from your local machine(note accessing traffic from a pod inside the cluster would work irrespective SH is configured correctly or not) +# 1. Acsessing from worker node +# * ssh into any worker node +# * Install the mongo shell +# * Create files from the two files mentioned above. (server.pem and ca.crt) +# * Run "mongo "mongodb://${WORKER_NODE}:30100,${WORKER_NODE}:30101,${WORKER_NODE}:30102/?replicaSet=test-tls-base-rs-external-access" --tls --tlsCertificateKeyFile server.pem --tlsCAFile ca.crt" +# 2. Accessing from local machine +# * Install the mongo shell +# * Create files from the two files mentioned above. (server.pem and ca.crt) +# * Open access to KOPS nodes from your local machine by following these steps(by default KOPS doesn't expose traffic from all ports to the internet) +# : https://stackoverflow.com/questions/45543694/kubernetes-cluster-on-aws-with-kops-nodeport-service-unavailable/45561848#45561848 +# * Run "mongo "mongodb://${WORKER_NODE}:30100,${WORKER_NODE}:30101,${WORKER_NODE}:30102/?replicaSet=test-tls-base-rs-external-access" --tls --tlsCertificateKeyFile server.pem --tlsCAFile ca.crt" +# When split horizon is not configured, specifying the replicaset name should fail. +# When split horizon is configured, it will successfully connect to the primary. + +# 6. Clean the namespace +# * This test creates node ports, which we should delete. + + +@pytest.fixture(scope="module") +def worker_node_hostname() -> str: + return WORKER_NODE_HOSTNAME + + +@pytest.fixture(scope="module") +def server_certs(issuer: str, namespace: str, worker_node_hostname: str): + mdb_resource = "test-tls-base-rs-external-access" + return create_mongodb_tls_certs( + ISSUER_CA_NAME, + namespace, + mdb_resource, + f"{mdb_resource}-cert", + replicas=3, + additional_domains=[ + worker_node_hostname, + ], + ) + + +@pytest.fixture(scope="module") +def node_ports() -> List[int]: + return [30100, 30101, 30102] + + +@pytest.fixture(scope="module") +def mdb( + namespace: str, + server_certs: str, + issuer_ca_configmap: str, + worker_node_hostname: str, + node_ports: List[int], +) -> MongoDB: + res = MongoDB.from_yaml( + load_fixture("test-tls-base-rs-external-access.yaml"), namespace=namespace + ) + res["spec"]["security"]["tls"]["ca"] = issuer_ca_configmap + res["spec"]["connectivity"]["replicaSetHorizons"] = [ + {"test-horizon": f"{worker_node_hostname}:{node_ports[0]}"}, + {"test-horizon": f"{worker_node_hostname}:{node_ports[1]}"}, + {"test-horizon": f"{worker_node_hostname}:{node_ports[2]}"}, + ] + + return res.create() + + +@pytest.mark.e2e_tls_rs_external_access_manual_connectivity +def test_mdb_reaches_running_state(mdb: MongoDB): + mdb.assert_reaches_phase(Phase.Running, timeout=900) + + +@pytest.mark.e2e_tls_rs_external_access_manual_connectivity +def test_create_node_ports(mdb: MongoDB, node_ports: List[int]): + for i in range(mdb["spec"]["members"]): + with open( + load_fixture("node-port-service.yaml"), + "r", + ) as f: + service_body = yaml.safe_load(f.read()) + service_body["metadata"][ + "name" + ] = f"{mdb.name}-external-access-svc-external-{i}" + service_body["spec"]["ports"][0]["nodePort"] = node_ports[i] + service_body["spec"]["selector"][ + "statefulset.kubernetes.io/pod-name" + ] = f"{mdb.name}-{i}" + + KubernetesTester.create_service( + mdb.namespace, + body=service_body, + ) diff --git a/docker/mongodb-enterprise-tests/tests/tls/tls_rs_external_access_transitions_without_approval.py b/docker/mongodb-enterprise-tests/tests/tls/tls_rs_external_access_transitions_without_approval.py new file mode 100644 index 000000000..f583b83b0 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/tls_rs_external_access_transitions_without_approval.py @@ -0,0 +1,88 @@ +import pytest + +from kubetester.omtester import get_rs_cert_names +from kubetester.kubetester import KubernetesTester, skip_if_local +from kubetester.mongotester import ReplicaSetTester + + +@pytest.mark.e2e_tls_rs_external_access_tls_transition_without_approval +class TestReplicaSetWithExternalAccess(KubernetesTester): + init = { + "create": { + "file": "test-tls-base-rs-external-access.yaml", + "wait_for_message": "Not all certificates have been approved by Kubernetes CA", + "timeout": 60, + "patch": [ + {"op": "remove", "path": "/spec/connectivity/replicaSetHorizons"} + ], + } + } + + def test_tls_certs_approved(self): + pass + + +@pytest.mark.e2e_tls_rs_external_access_tls_transition_without_approval +class TestReplicaSetExternalAccessAddHorizons(KubernetesTester): + init = { + "update": { + "file": "test-tls-base-rs-external-access.yaml", + "wait_for_message": "Please manually remove the CSR in order to proceed.", + "timeout": 60, + } + } + + def test_certs_approved(self): + csr_names = get_rs_cert_names( + "test-tls-base-rs-external-access", self.namespace + ) + for csr_name in self.yield_existing_csrs(csr_names): + self.delete_csr(csr_name) + self.wait_for_status_message( + { + "wait_for_message": "Not all certificates have been approved", + "timeout": 30, + } + ) + for csr_name in self.yield_existing_csrs(csr_names): + self.approve_certificate(csr_name) + KubernetesTester.wait_until("in_running_state", 360) + + @skip_if_local + def test_can_still_connect(self): + tester = ReplicaSetTester("test-tls-base-rs-external-access", 3, ssl=True) + tester.assert_connectivity() + + def test_automation_config_is_right(self): + ac = self.get_automation_config() + members = ac["replicaSets"][0]["members"] + horizon_names = [m["horizons"] for m in members] + assert horizon_names == [ + {"test-horizon": "mdb0-test-website.com:1337"}, + {"test-horizon": "mdb1-test-website.com:1337"}, + {"test-horizon": "mdb2-test-website.com:1337"}, + ] + + @skip_if_local + def test_has_right_certs(self): + """ + Check that mongod processes behind the replica set service are + serving the right certificates. + """ + host = f"test-tls-base-rs-external-access-svc.{self.namespace}.svc" + assert any( + san.endswith("test-website.com") for san in self.get_mongo_server_sans(host) + ) + + +@pytest.mark.e2e_tls_rs_external_access_tls_transition_without_approval +class TestExternalAccessDeletion(KubernetesTester): + """ + delete: + file: test-tls-base-rs-external-access.yaml + wait_until: mongo_resource_deleted_no_om + timeout: 240 + """ + + def test_deletion(self): + assert True diff --git a/docker/mongodb-enterprise-tests/tests/tls/tls_rs_intermediate_ca.py b/docker/mongodb-enterprise-tests/tests/tls/tls_rs_intermediate_ca.py new file mode 100644 index 000000000..69259312c --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/tls_rs_intermediate_ca.py @@ -0,0 +1,46 @@ +import re +import pytest +from kubetester.kubetester import KubernetesTester, skip_if_local +from kubetester.kubetester import fixture as load_fixture +from kubetester.mongodb import MongoDB, Phase +from kubetester.certs import create_mongodb_tls_certs + + +MDB_RESOURCE_NAME = "test-tls-rs-intermediate-ca" + + +@pytest.fixture(scope="module") +def server_certs(intermediate_issuer: str, namespace: str): + return create_mongodb_tls_certs( + intermediate_issuer, + namespace, + MDB_RESOURCE_NAME, + f"{MDB_RESOURCE_NAME}-cert", + additional_domains=[ + "test-tls-rs-intermediate-ca-0.additional-cert-test.com", + "test-tls-rs-intermediate-ca-1.additional-cert-test.com", + "test-tls-rs-intermediate-ca-2.additional-cert-test.com", + ], + ) + + +@pytest.fixture(scope="module") +def mdb(namespace: str, server_certs: str, issuer_ca_configmap: str) -> MongoDB: + res = MongoDB.from_yaml( + load_fixture("test-tls-additional-domains.yaml"), + namespace=namespace, + name=MDB_RESOURCE_NAME, + ) + res["spec"]["security"]["tls"]["ca"] = issuer_ca_configmap + return res.create() + + +@pytest.mark.e2e_tls_rs_intermediate_ca +class TestReplicaSetWithAdditionalCertDomains(KubernetesTester): + def test_replica_set_is_running(self, mdb: MongoDB): + mdb.assert_reaches_phase(Phase.Running, timeout=400) + + @skip_if_local + def test_can_still_connect(self, mdb: MongoDB, ca_path: str): + tester = mdb.tester(use_ssl=True, ca_path=ca_path) + tester.assert_connectivity() diff --git a/docker/mongodb-enterprise-tests/tests/tls/tls_sc_additional_certs.py b/docker/mongodb-enterprise-tests/tests/tls/tls_sc_additional_certs.py new file mode 100644 index 000000000..ce97ceb91 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/tls_sc_additional_certs.py @@ -0,0 +1,77 @@ +import re +import pytest +from kubetester.omtester import get_sc_cert_names +from kubetester.kubetester import KubernetesTester, skip_if_local +from kubetester.mongotester import ShardedClusterTester +from kubetester.mongodb import MongoDB, Phase +from kubetester.kubetester import fixture as load_fixture +from kubetester.certs import ( + ISSUER_CA_NAME, + create_mongodb_tls_certs, + create_agent_tls_certs, + create_sharded_cluster_certs, +) +import time +import jsonpatch + +MDB_RESOURCE_NAME = "test-tls-sc-additional-domains" + + +@pytest.fixture(scope="module") +def server_certs(issuer: str, namespace: str): + create_sharded_cluster_certs( + namespace, + MDB_RESOURCE_NAME, + shards=1, + mongos_per_shard=1, + config_servers=1, + mongos=2, + additional_domains=["additional-cert-test.com"], + ) + + +@pytest.fixture(scope="module") +def sc(namespace: str, server_certs: str, issuer_ca_configmap: str) -> MongoDB: + res = MongoDB.from_yaml(load_fixture("test-tls-sc-additional-domains.yaml"), namespace=namespace) + res["spec"]["security"]["tls"]["ca"] = issuer_ca_configmap + return res.create() + + +@pytest.mark.e2e_tls_sc_additional_certs +class TestShardedClustertWithAdditionalCertDomains(KubernetesTester): + def test_sharded_cluster_running(self, sc: MongoDB): + sc.assert_reaches_phase(Phase.Running, timeout=1200) + + @skip_if_local + def test_has_right_certs(self): + """Check that mongos processes serving the right certificates.""" + for i in range(2): + host = f"{MDB_RESOURCE_NAME}-mongos-{i}.{MDB_RESOURCE_NAME}-svc.{self.namespace}.svc" + assert any( + re.match(rf"{MDB_RESOURCE_NAME}-mongos-{i}\.additional-cert-test\.com", san) + for san in self.get_mongo_server_sans(host) + ) + + @skip_if_local + def test_can_still_connect(self, ca_path: str): + tester = ShardedClusterTester(MDB_RESOURCE_NAME, ssl=True, ca_path=ca_path, mongos_count=2) + tester.assert_connectivity() + + +@pytest.mark.e2e_tls_sc_additional_certs +class TestShardedClustertRemoveAdditionalCertDomains(KubernetesTester): + """ + update: + file: test-tls-sc-additional-domains.yaml + wait_until: in_running_state + patch: '[{"op":"remove","path":"/spec/security/tls/additionalCertificateDomains"}]' + timeout: 240 + """ + + def test_continues_to_work(self): + pass + + @skip_if_local + def test_can_still_connect(self, sc: MongoDB, ca_path: str): + tester = sc.tester(use_ssl=True, ca_path=ca_path) + tester.assert_connectivity() diff --git a/docker/mongodb-enterprise-tests/tests/tls/tls_sc_requiressl_custom_ca.py b/docker/mongodb-enterprise-tests/tests/tls/tls_sc_requiressl_custom_ca.py new file mode 100644 index 000000000..6438d3281 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/tls_sc_requiressl_custom_ca.py @@ -0,0 +1,103 @@ +import pytest + +from kubernetes import client +from kubetester.kubetester import KubernetesTester, skip_if_local +from kubetester.mongotester import ShardedClusterTester +from kubetester.mongodb import MongoDB, Phase +from kubetester.kubetester import fixture as load_fixture +from kubetester.certs import ( + ISSUER_CA_NAME, + create_mongodb_tls_certs, + create_sharded_cluster_certs, + Certificate, +) + +from typing import Dict, List + +MDB_RESOURCE_NAME = "test-tls-base-sc-require-ssl" + + +@pytest.fixture(scope="module") +def server_certs(issuer: str, namespace: str): + create_sharded_cluster_certs( + namespace, + MDB_RESOURCE_NAME, + shards=1, + mongos_per_shard=3, + config_servers=3, + mongos=2, + ) + + +@pytest.fixture(scope="module") +def sharded_cluster( + namespace: str, server_certs: str, issuer_ca_configmap: str +) -> MongoDB: + res = MongoDB.from_yaml( + load_fixture("test-tls-base-sc-require-ssl-custom-ca.yaml"), namespace=namespace + ) + res["spec"]["security"]["tls"]["ca"] = issuer_ca_configmap + return res.create() + + +@pytest.mark.e2e_sharded_cluster_tls_require_custom_ca +class TestClusterWithTLSCreation(KubernetesTester): + def test_sharded_cluster_running(self, sharded_cluster: MongoDB): + sharded_cluster.assert_reaches_phase(Phase.Running, timeout=1200) + + @skip_if_local + def test_mongos_are_reachable_with_ssl(self, ca_path: str): + tester = ShardedClusterTester( + MDB_RESOURCE_NAME, ssl=True, ca_path=ca_path, mongos_count=2 + ) + tester.assert_connectivity() + + @skip_if_local + def test_mongos_are_not_reachable_with_no_ssl(self): + tester = ShardedClusterTester(MDB_RESOURCE_NAME, mongos_count=2) + tester.assert_no_connection() + + +@pytest.mark.e2e_sharded_cluster_tls_require_custom_ca +class TestClusterWithTLSCreationRunning(KubernetesTester): + def test_mdb_should_reach_goal_state_after_scaling(self, sharded_cluster: MongoDB): + sharded_cluster.load() + sharded_cluster["spec"]["mongodsPerShardCount"] = 3 + sharded_cluster.update() + sharded_cluster.assert_reaches_phase(Phase.Running, timeout=1200) + + @skip_if_local + def test_mongos_are_reachable_with_ssl(self, ca_path: str): + tester = ShardedClusterTester( + MDB_RESOURCE_NAME, ssl=True, ca_path=ca_path, mongos_count=2 + ) + tester.assert_connectivity() + + @skip_if_local + def test_mongos_are_not_reachable_with_no_ssl(self): + tester = ShardedClusterTester(MDB_RESOURCE_NAME, ssl=False, mongos_count=2) + tester.assert_no_connection() + + +@pytest.mark.e2e_sharded_cluster_tls_require_custom_ca +class TestCertificateIsRenewed(KubernetesTester): + def test_mdb_reconciles_succesfully(self, sharded_cluster: MongoDB, namespace: str): + cert = Certificate( + name=f"{MDB_RESOURCE_NAME}-0-cert", namespace=namespace + ).load() + cert["spec"]["dnsNames"].append("foo") + cert.update() + sharded_cluster.assert_abandons_phase(Phase.Running, timeout=60) + sharded_cluster.assert_reaches_phase(Phase.Running, timeout=1200) + + @skip_if_local + def test_mongos_are_reachable_with_ssl(self, ca_path: str): + tester = ShardedClusterTester( + MDB_RESOURCE_NAME, ssl=True, ca_path=ca_path, mongos_count=2 + ) + tester.assert_connectivity() + + @skip_if_local + def test_mongos_are_not_reachable_with_no_ssl(self): + tester = ShardedClusterTester(MDB_RESOURCE_NAME, mongos_count=2) + tester.assert_no_connection() diff --git a/docker/mongodb-enterprise-tests/tests/tls/tls_sharded_cluster_certsSecretPrefix.py b/docker/mongodb-enterprise-tests/tests/tls/tls_sharded_cluster_certsSecretPrefix.py new file mode 100644 index 000000000..189f51361 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/tls_sharded_cluster_certsSecretPrefix.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 + +import pytest + +from kubernetes import client +from kubetester.kubetester import KubernetesTester, skip_if_local +from kubetester.mongotester import ShardedClusterTester +from kubetester.mongodb import MongoDB, Phase +from kubetester.kubetester import fixture as load_fixture +from kubetester.certs import ( + ISSUER_CA_NAME, + create_mongodb_tls_certs, + create_sharded_cluster_certs, +) + +from typing import Dict, List + +MDB_RESOURCE_NAME = "test-tls-base-sc-require-ssl" + + +@pytest.fixture(scope="module") +def server_certs(issuer: str, namespace: str): + create_sharded_cluster_certs( + namespace, + MDB_RESOURCE_NAME, + shards=1, + mongos_per_shard=3, + config_servers=3, + mongos=2, + secret_prefix="prefix-", + ) + + +@pytest.fixture(scope="module") +def sharded_cluster( + namespace: str, server_certs: str, issuer_ca_configmap: str +) -> MongoDB: + res = MongoDB.from_yaml( + load_fixture("test-tls-base-sc-require-ssl-custom-ca.yaml"), namespace=namespace + ) + res["spec"]["security"]["tls"] = {"ca": issuer_ca_configmap} + # Setting security.certsSecretPrefix implicitly enables TLS + res["spec"]["security"]["certsSecretPrefix"] = "prefix" + return res.create() + + +@pytest.mark.e2e_sharded_cluster_tls_certs_top_level_prefix +class TestClusterWithTLSCreation(KubernetesTester): + def test_sharded_cluster_running(self, sharded_cluster: MongoDB): + sharded_cluster.assert_reaches_phase(Phase.Running, timeout=1200) + + @skip_if_local + def test_mongos_are_reachable_with_ssl(self, ca_path: str): + tester = ShardedClusterTester( + MDB_RESOURCE_NAME, ssl=True, ca_path=ca_path, mongos_count=2 + ) + tester.assert_connectivity() + + @skip_if_local + def test_mongos_are_not_reachable_with_no_ssl(self): + tester = ShardedClusterTester(MDB_RESOURCE_NAME, mongos_count=2) + tester.assert_no_connection() diff --git a/docker/mongodb-enterprise-tests/tests/tls/tls_sharded_cluster_certs_prefix.py b/docker/mongodb-enterprise-tests/tests/tls/tls_sharded_cluster_certs_prefix.py new file mode 100644 index 000000000..71a1683a3 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/tls_sharded_cluster_certs_prefix.py @@ -0,0 +1,159 @@ +import json + +from kubetester.certs import create_mongodb_tls_certs, SetProperties +from kubetester.mongodb import MongoDB, Phase + +from kubetester.kubetester import skip_if_local, KubernetesTester +from kubetester.kubetester import fixture as _fixture +from pytest import mark, fixture + +MDB_RESOURCE = "sharded-cluster-custom-certs" +SUBJECT = {"organizations": ["MDB Tests"], "organizationalUnits": ["Servers"]} +SERVER_SETS = frozenset( + [ + SetProperties(MDB_RESOURCE + "-0", MDB_RESOURCE + "-sh", 3), + SetProperties(MDB_RESOURCE + "-config", MDB_RESOURCE + "-cs", 3), + SetProperties(MDB_RESOURCE + "-mongos", MDB_RESOURCE + "-svc", 2), + ] +) + + +@fixture(scope="module") +def all_certs(issuer, namespace) -> None: + """Generates all required TLS certificates: Servers and Client/Member.""" + spec = { + "subject": SUBJECT, + "usages": ["server auth", "client auth"], + } + + for server_set in SERVER_SETS: + create_mongodb_tls_certs( + issuer, + namespace, + server_set.name, + "prefix-" + server_set.name + "-cert", + server_set.replicas, + server_set.service, + spec, + ) + + +@fixture(scope="module") +def sharded_cluster( + namespace: str, + all_certs, + issuer_ca_configmap: str, +) -> MongoDB: + mdb: MongoDB = MongoDB.from_yaml( + _fixture("test-tls-base-sc-require-ssl.yaml"), + name=MDB_RESOURCE, + namespace=namespace, + ) + mdb["spec"]["security"] = { + "tls": { + "enabled": True, + "ca": issuer_ca_configmap, + }, + "certsSecretPrefix": "prefix", + } + return mdb.create() + + +@mark.e2e_tls_sharded_cluster_certs_prefix +def test_sharded_cluster_with_prefix_gets_to_running_state(sharded_cluster: MongoDB): + sharded_cluster.assert_reaches_phase(Phase.Running, timeout=1200) + + +@mark.e2e_tls_sharded_cluster_certs_prefix +@skip_if_local +def test_sharded_cluster_has_connectivity_with_tls( + sharded_cluster: MongoDB, ca_path: str +): + tester = sharded_cluster.tester(ca_path=ca_path, use_ssl=True) + tester.assert_connectivity() + + +@mark.e2e_tls_sharded_cluster_certs_prefix +@skip_if_local +def test_sharded_cluster_has_no_connectivity_without_tls(sharded_cluster: MongoDB): + tester = sharded_cluster.tester(use_ssl=False) + tester.assert_no_connection() + + +@mark.e2e_tls_sharded_cluster_certs_prefix +def test_disable_tls(sharded_cluster: MongoDB): + + last_transition = sharded_cluster.get_status_last_transition_time() + sharded_cluster.load() + sharded_cluster["spec"]["security"]["tls"]["enabled"] = False + sharded_cluster.update() + + sharded_cluster.assert_state_transition_happens(last_transition) + sharded_cluster.assert_reaches_phase(Phase.Running, timeout=1200) + + +@mark.e2e_tls_sharded_cluster_certs_prefix +@mark.xfail( + reason="Disabling security.tls.enabled does not disable TLS when security.tls.secretRef.prefix is set" +) +@skip_if_local +def test_sharded_cluster_has_connectivity_without_tls(sharded_cluster: MongoDB): + tester = sharded_cluster.tester(use_ssl=False) + tester.assert_connectivity() + + +@mark.e2e_tls_sharded_cluster_certs_prefix +def test_sharded_cluster_with_allow_tls(sharded_cluster: MongoDB): + sharded_cluster.load() + + sharded_cluster["spec"]["security"]["tls"]["enabled"] = True + + additional_mongod_config = { + "additionalMongodConfig": { + "net": { + "tls": { + "mode": "allowTLS", + } + } + } + } + + sharded_cluster["spec"]["mongos"] = additional_mongod_config + sharded_cluster["spec"]["shard"] = additional_mongod_config + sharded_cluster["spec"]["configSrv"] = additional_mongod_config + + sharded_cluster.update() + sharded_cluster.assert_abandons_phase(Phase.Running) + sharded_cluster.assert_reaches_phase(Phase.Running, timeout=1200) + + automation_config = KubernetesTester.get_automation_config() + + tls_modes = [ + process.get("args2_6", {}).get("net", {}).get("tls", {}).get("mode") + for process in automation_config["processes"] + ] + + # 3 mongod + 3 configSrv + 2 mongos = 8 processes + assert len(tls_modes) == 8 + tls_modes_set = set(tls_modes) + # all processes should have the same allowTLS value + assert len(tls_modes_set) == 1 + assert tls_modes_set.pop() == "allowTLS" + + +@mark.e2e_tls_sharded_cluster_certs_prefix +@skip_if_local +def test_sharded_cluster_has_connectivity_with_tls_with_allow_tls_mode( + sharded_cluster: MongoDB, ca_path: str +): + tester = sharded_cluster.tester(ca_path=ca_path, use_ssl=True) + tester.assert_connectivity() + + +@mark.e2e_tls_sharded_cluster_certs_prefix +@skip_if_local +def test_sharded_cluster_has_connectivity_without_tls_with_allow_tls_mode( + sharded_cluster: MongoDB, +): + tester = sharded_cluster.tester(use_ssl=False) + tester.assert_connectivity() diff --git a/docker/mongodb-enterprise-tests/tests/tls/tls_x509_configure_all_options_rs.py b/docker/mongodb-enterprise-tests/tests/tls/tls_x509_configure_all_options_rs.py new file mode 100644 index 000000000..158faf2df --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/tls_x509_configure_all_options_rs.py @@ -0,0 +1,53 @@ +import pytest + +from kubetester.kubetester import ( + KubernetesTester, + SERVER_WARNING, + AGENT_WARNING, + MEMBER_AUTH_WARNING, +) +from kubetester.omtester import get_rs_cert_names +from kubetester import create_secret, read_secret, create_secret, create_or_update +from kubetester.automation_config_tester import AutomationConfigTester +from kubetester.kubetester import fixture as load_fixture, skip_if_local +from kubetester.mongodb import MongoDB, Phase +from kubetester.certs import ( + ISSUER_CA_NAME, + create_mongodb_tls_certs, + create_x509_mongodb_tls_certs, + create_x509_agent_tls_certs, +) + +MDB_RESOURCE = "test-x509-all-options-rs" + + +@pytest.fixture(scope="module") +def server_certs(issuer: str, namespace: str): + create_x509_mongodb_tls_certs(ISSUER_CA_NAME, namespace, MDB_RESOURCE, f"{MDB_RESOURCE}-cert") + secret_name = f"{MDB_RESOURCE}-cert" + data = read_secret(namespace, secret_name) + secret_type = "kubernetes.io/tls" + create_secret(namespace, f"{MDB_RESOURCE}-clusterfile", data, type=secret_type) + + +@pytest.fixture(scope="module") +def agent_certs(issuer: str, namespace: str) -> str: + return create_x509_agent_tls_certs(issuer, namespace, MDB_RESOURCE) + + +@pytest.fixture(scope="module") +def mdb(namespace: str, server_certs: str, agent_certs: str, issuer_ca_configmap: str) -> MongoDB: + res = MongoDB.from_yaml(load_fixture("test-x509-all-options-rs.yaml"), namespace=namespace) + res["spec"]["security"]["tls"]["ca"] = issuer_ca_configmap + return create_or_update(res) + + +@pytest.mark.e2e_tls_x509_configure_all_options_rs +class TestReplicaSetEnableAllOptions(KubernetesTester): + def test_gets_to_running_state(self, mdb: MongoDB): + mdb.assert_reaches_phase(Phase.Running, timeout=600) + + def test_ops_manager_state_correctly_updated(self): + ac_tester = AutomationConfigTester(KubernetesTester.get_automation_config()) + ac_tester.assert_internal_cluster_authentication_enabled() + ac_tester.assert_authentication_enabled() diff --git a/docker/mongodb-enterprise-tests/tests/tls/tls_x509_configure_all_options_sc.py b/docker/mongodb-enterprise-tests/tests/tls/tls_x509_configure_all_options_sc.py new file mode 100644 index 000000000..33b0cadc4 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/tls_x509_configure_all_options_sc.py @@ -0,0 +1,74 @@ +import pytest +from pytest import fixture + +from kubetester import create_or_update +from kubetester import find_fixture +from kubetester.automation_config_tester import AutomationConfigTester +from kubetester.certs import ( + create_x509_agent_tls_certs, + create_sharded_cluster_certs, + Certificate, +) +from kubetester.kubetester import KubernetesTester +from kubetester.mongodb import MongoDB, Phase + +MDB_RESOURCE_NAME = "test-x509-all-options-sc" + + +@fixture(scope="module") +def agent_certs(issuer: str, namespace: str) -> str: + return create_x509_agent_tls_certs(issuer, namespace, MDB_RESOURCE_NAME) + + +@fixture(scope="module") +def server_certs(issuer: str, namespace: str): + create_sharded_cluster_certs( + namespace, + MDB_RESOURCE_NAME, + shards=1, + mongos_per_shard=3, + config_servers=3, + mongos=2, + internal_auth=True, + x509_certs=True, + ) + + +@fixture(scope="module") +def sharded_cluster(namespace: str, server_certs: str, agent_certs: str, issuer_ca_configmap: str) -> MongoDB: + resource = MongoDB.from_yaml( + find_fixture("test-x509-all-options-sc.yaml"), + namespace=namespace, + ) + resource["spec"]["security"]["tls"]["ca"] = issuer_ca_configmap + yield create_or_update(resource) + + +@pytest.mark.e2e_tls_x509_configure_all_options_sc +class TestShardedClusterEnableAllOptions(KubernetesTester): + def test_gets_to_running_state(self, sharded_cluster: MongoDB): + sharded_cluster.assert_reaches_phase(Phase.Running, timeout=600) + + def test_ops_manager_state_correctly_updated(self): + ac_tester = AutomationConfigTester(KubernetesTester.get_automation_config()) + ac_tester.assert_internal_cluster_authentication_enabled() + ac_tester.assert_authentication_enabled() + ac_tester.assert_expected_users(0) + + def test_rotate_shard_certfile(self, sharded_cluster: MongoDB, namespace: str): + assert_certificate_rotation(sharded_cluster, namespace, "{}-0-clusterfile".format(MDB_RESOURCE_NAME)) + + def test_rotate_config_certfile(self, sharded_cluster: MongoDB, namespace: str): + assert_certificate_rotation(sharded_cluster, namespace, "{}-config-clusterfile".format(MDB_RESOURCE_NAME)) + + def test_rotate_mongos_certfile(self, sharded_cluster: MongoDB, namespace: str): + assert_certificate_rotation(sharded_cluster, namespace, "{}-mongos-clusterfile".format(MDB_RESOURCE_NAME)) + + +def assert_certificate_rotation(sharded_cluster, namespace, certificate_name): + cert = Certificate(name=certificate_name, namespace=namespace) + cert.load() + cert["spec"]["dnsNames"].append("foo") # Append DNS to cert to rotate the certificate + cert.update() + sharded_cluster.assert_abandons_phase(Phase.Running, timeout=120) + sharded_cluster.assert_reaches_phase(Phase.Running, timeout=900) diff --git a/docker/mongodb-enterprise-tests/tests/tls/tls_x509_rs.py b/docker/mongodb-enterprise-tests/tests/tls/tls_x509_rs.py new file mode 100644 index 000000000..c3d8e934f --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/tls_x509_rs.py @@ -0,0 +1,65 @@ +import pytest +from kubetester.kubetester import KubernetesTester, skip_if_local +from kubetester.omtester import get_rs_cert_names + +from kubetester.mongotester import ReplicaSetTester +from kubetester.kubetester import fixture as load_fixture +from kubetester.mongodb import MongoDB, Phase +from kubetester.certs import ( + ISSUER_CA_NAME, + create_mongodb_tls_certs, + create_agent_tls_certs, +) + +MDB_RESOURCE = "test-x509-rs" + + +@pytest.fixture(scope="module") +def server_certs(issuer: str, namespace: str): + return create_mongodb_tls_certs( + ISSUER_CA_NAME, namespace, MDB_RESOURCE, f"{MDB_RESOURCE}-cert" + ) + + +@pytest.fixture(scope="module") +def agent_certs(issuer: str, namespace: str) -> str: + return create_agent_tls_certs(issuer, namespace, MDB_RESOURCE) + + +@pytest.fixture(scope="module") +def mdb( + namespace: str, server_certs: str, agent_certs: str, issuer_ca_configmap: str +) -> MongoDB: + res = MongoDB.from_yaml(load_fixture("test-x509-rs.yaml"), namespace=namespace) + res["spec"]["security"]["tls"]["ca"] = issuer_ca_configmap + return res.create() + + +@pytest.mark.e2e_tls_x509_rs +class TestReplicaSetWithNoTLSCreation(KubernetesTester): + def test_gets_to_running_state(self, mdb: MongoDB): + mdb.assert_reaches_phase(Phase.Running, timeout=1200) + + @skip_if_local + def test_mdb_is_reachable_with_no_ssl(self): + tester = ReplicaSetTester(MDB_RESOURCE, 3) + tester.assert_no_connection() + + @skip_if_local + def test_mdb_is_reachable_with_ssl(self): + # This one will also fails, as it expects x509 client certs which we are not passing. + tester = ReplicaSetTester(MDB_RESOURCE, 3, ssl=True) + tester.assert_no_connection() + + +@pytest.mark.e2e_tls_x509_rs +class TestReplicaSetWithNoTLSDeletion(KubernetesTester): + """ + delete: + file: test-x509-rs.yaml + wait_until: mongo_resource_deleted_no_om + timeout: 240 + """ + + def test_deletion(self): + assert True diff --git a/docker/mongodb-enterprise-tests/tests/tls/tls_x509_sc.py b/docker/mongodb-enterprise-tests/tests/tls/tls_x509_sc.py new file mode 100644 index 000000000..7f19f7a70 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/tls_x509_sc.py @@ -0,0 +1,49 @@ +import pytest + +from kubernetes import client +from kubetester.kubetester import KubernetesTester, skip_if_local +from kubetester.mongotester import ShardedClusterTester +from kubetester.mongodb import MongoDB, Phase +from kubetester.kubetester import fixture as load_fixture +from kubetester.certs import ( + ISSUER_CA_NAME, + create_mongodb_tls_certs, + create_x509_agent_tls_certs, + create_sharded_cluster_certs, +) + +from typing import Dict, List + +MDB_RESOURCE_NAME = "test-x509-sc" + + +@pytest.fixture(scope="module") +def server_certs(issuer: str, namespace: str): + create_sharded_cluster_certs( + namespace, + MDB_RESOURCE_NAME, + shards=1, + mongos_per_shard=3, + config_servers=3, + mongos=2, + ) + + +@pytest.fixture(scope="module") +def agent_certs(issuer: str, namespace: str) -> str: + return create_x509_agent_tls_certs(issuer, namespace, MDB_RESOURCE_NAME) + + +@pytest.fixture(scope="module") +def sharded_cluster( + namespace: str, server_certs: str, agent_certs: str, issuer_ca_configmap: str +) -> MongoDB: + res = MongoDB.from_yaml(load_fixture("test-x509-sc.yaml"), namespace=namespace) + res["spec"]["security"]["tls"]["ca"] = issuer_ca_configmap + return res.create() + + +@pytest.mark.e2e_tls_x509_sc +class TestClusterWithTLSCreation(KubernetesTester): + def test_sharded_cluster_running(self, sharded_cluster: MongoDB): + sharded_cluster.assert_reaches_phase(Phase.Running, timeout=1200) diff --git a/docker/mongodb-enterprise-tests/tests/tls/tls_x509_user_connectivity.py b/docker/mongodb-enterprise-tests/tests/tls/tls_x509_user_connectivity.py new file mode 100644 index 000000000..a9df93f4e --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/tls/tls_x509_user_connectivity.py @@ -0,0 +1,111 @@ +import pytest +from kubetester.kubetester import KubernetesTester, fixture as _fixture +from kubetester.mongotester import ReplicaSetTester +from kubetester.automation_config_tester import AutomationConfigTester + +from kubetester.certs import ( + ISSUER_CA_NAME, + create_mongodb_tls_certs, + create_agent_tls_certs, + create_x509_user_cert, +) +import tempfile +from kubetester.mongodb import MongoDB, Phase + + +MDB_RESOURCE = "test-x509-rs" +X509_AGENT_SUBJECT = "CN=automation,OU={namespace},O=cert-manager" + + +@pytest.fixture(scope="module") +def agent_certs(issuer: str, namespace: str) -> str: + return create_agent_tls_certs(issuer, namespace, MDB_RESOURCE) + + +@pytest.fixture(scope="module") +def server_certs(issuer: str, namespace: str) -> str: + return create_mongodb_tls_certs( + issuer, namespace, MDB_RESOURCE, MDB_RESOURCE + "-cert" + ) + + +@pytest.fixture(scope="module") +def replica_set(namespace, agent_certs, server_certs, issuer_ca_configmap): + _ = server_certs + res = MongoDB.from_yaml(_fixture("test-x509-rs.yaml"), namespace=namespace) + res["spec"]["security"]["tls"]["ca"] = issuer_ca_configmap + + return res.create() + + +@pytest.mark.e2e_tls_x509_user_connectivity +class TestReplicaSetWithTLSCreation(KubernetesTester): + def test_users_wanted_is_correct(self, replica_set, namespace): + """At this stage we should have 2 members in the usersWanted list, + monitoring-agent and backup-agent.""" + + replica_set.assert_reaches_phase(Phase.Running, timeout=600) + + automation_config = KubernetesTester.get_automation_config() + users = [u["user"] for u in automation_config["auth"]["usersWanted"]] + + for subject in users: + names = dict(name.split("=") for name in subject.split(",")) + + assert "OU" in names + assert "CN" in names + + +@pytest.mark.e2e_tls_x509_user_connectivity +class TestAddMongoDBUser(KubernetesTester): + """ + create: + file: test-x509-user.yaml + patch: '[{"op":"replace","path":"/spec/mongodbResourceRef/name","value": "test-x509-rs" }]' + wait_until: user_exists + """ + + def test_add_user(self): + assert True + + @staticmethod + def user_exists(): + ac = KubernetesTester.get_automation_config() + users = ac["auth"]["usersWanted"] + + return "CN=x509-testing-user" in [user["user"] for user in users] + + +@pytest.mark.e2e_tls_x509_user_connectivity +class TestX509CertCreationAndApproval(KubernetesTester): + def setup(self): + self.cert_file = tempfile.NamedTemporaryFile(delete=False, mode="w") + + def test_create_user_and_authenticate( + self, issuer: str, namespace: str, ca_path: str + ): + create_x509_user_cert(issuer, namespace, path=self.cert_file.name) + tester = ReplicaSetTester(MDB_RESOURCE, 3) + tester.assert_x509_authentication( + cert_file_name=self.cert_file.name, ssl_ca_certs=ca_path + ) + + def teardown(self): + self.cert_file.close() + + +@pytest.mark.e2e_tls_x509_user_connectivity +class TestX509CorrectlyConfigured(KubernetesTester): + def test_om_state_is_correct(self, namespace): + automation_config = KubernetesTester.get_automation_config() + tester = AutomationConfigTester(automation_config) + + tester.assert_authentication_mechanism_enabled("MONGODB-X509") + tester.assert_authoritative_set(True) + tester.assert_expected_users(1) + + user = automation_config["auth"]["autoUser"] + names = dict(name.split("=") for name in user.split(",")) + + assert "OU" in names + assert "CN=mms-automation-agent" in user diff --git a/docker/mongodb-enterprise-tests/tests/upgrades/__init__.py b/docker/mongodb-enterprise-tests/tests/upgrades/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/docker/mongodb-enterprise-tests/tests/upgrades/operator_upgrade_appdb_tls.py b/docker/mongodb-enterprise-tests/tests/upgrades/operator_upgrade_appdb_tls.py new file mode 100644 index 000000000..1398fe70e --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/upgrades/operator_upgrade_appdb_tls.py @@ -0,0 +1,103 @@ +from pytest import mark, fixture + +from kubetester import get_statefulset +from kubetester.kubetester import fixture as yaml_fixture, skip_if_local +from kubetester.mongodb import Phase +from kubetester.operator import Operator +from kubetester.opsmanager import MongoDBOpsManager + +from tests.opsmanager.om_ops_manager_https import create_mongodb_tls_certs + +APPDB_NAME = "om-appdb-upgrade-tls-db" + +CERT_PREFIX = "prefix" + + +def appdb_name(namespace: str) -> str: + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_appdb_upgrade_tls.yaml"), namespace=namespace + ) + return "{}-db".format(resource["metadata"]["name"]) + + +@fixture(scope="module") +def appdb_certs_secret(namespace: str, issuer: str): + print(appdb_name) + return create_mongodb_tls_certs( + issuer, + namespace, + APPDB_NAME, + "{}-{}-cert".format(CERT_PREFIX, appdb_name(namespace)), + ) + + +@fixture(scope="module") +def ops_manager( + namespace, + issuer_ca_configmap: str, + appdb_certs_secret: str, +) -> MongoDBOpsManager: + resource: MongoDBOpsManager = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_appdb_upgrade_tls.yaml"), namespace=namespace + ) + resource["spec"]["applicationDatabase"]["security"]["certsSecretPrefix"] = CERT_PREFIX + + return resource.create() + + +@mark.e2e_operator_upgrade_appdb_tls +def test_install_latest_official_operator(official_operator: Operator): + official_operator.assert_is_running() + + +@mark.e2e_operator_upgrade_appdb_tls +def test_create_om(ops_manager: MongoDBOpsManager): + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=600) + + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=600) + + # appdb rolling restart for configuring monitoring + ops_manager.appdb_status().assert_abandons_phase(Phase.Running, timeout=200) + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=600) + + +@skip_if_local +@mark.e2e_operator_upgrade_appdb_tls +def test_om_is_ok(ops_manager: MongoDBOpsManager): + ops_manager.get_om_tester().assert_healthiness() + + +@mark.e2e_operator_upgrade_appdb_tls +def test_upgrade_operator(default_operator: Operator): + default_operator.assert_is_running() + + +@mark.e2e_operator_upgrade_appdb_tls +def test_om_ok(ops_manager: MongoDBOpsManager): + # status phases are updated gradually - we need to check for each of them (otherwise "check(Running) for OM" + # will return True right away + ops_manager.appdb_status().assert_abandons_phase(Phase.Running, timeout=100) + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=800) + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=500) + + ops_manager.get_om_tester().assert_healthiness() + + +@mark.e2e_operator_upgrade_appdb_tls +def test_using_official_images( + namespace: str, +): + """ + This test ensures that after upgrading from 1.x to 1.20 that our operator automatically replaces the old appdb + image with the official on + """ + # -> old quay.io/mongodb/mongodb-enterprise-appdb-database-ubi:5.0.14-ent + # -> new quay.io/mongodb/mongodb-enterprise-server:5.0.14-ubi8 + sts = get_statefulset(namespace, APPDB_NAME) + found_official_image = any( + [ + "quay.io/mongodb/mongodb-enterprise-server" in container.image + for container in sts.spec.template.spec.containers + ] + ) + assert found_official_image diff --git a/docker/mongodb-enterprise-tests/tests/upgrades/operator_upgrade_ops_manager.py b/docker/mongodb-enterprise-tests/tests/upgrades/operator_upgrade_ops_manager.py new file mode 100644 index 000000000..7f0c6cfda --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/upgrades/operator_upgrade_ops_manager.py @@ -0,0 +1,177 @@ +from typing import Optional + +from kubetester.awss3client import AwsS3Client +from kubetester.kubetester import ( + skip_if_local, + fixture as yaml_fixture, + KubernetesTester, +) +from kubetester.mongodb import MongoDB, Phase +from kubetester.mongotester import MongoDBBackgroundTester +from kubetester.operator import Operator +from kubetester.opsmanager import MongoDBOpsManager +from pytest import fixture, mark + + +@fixture(scope="module") +def s3_bucket(aws_s3_client: AwsS3Client, namespace: str) -> str: + """creates a s3 bucket and a s3 config""" + + bucket_name = KubernetesTester.random_k8s_name("test-bucket-") + aws_s3_client.create_s3_bucket(bucket_name) + print(f"\nCreated S3 bucket {bucket_name}") + + KubernetesTester.create_secret( + namespace, + "my-s3-secret", + { + "accessKey": aws_s3_client.aws_access_key, + "secretKey": aws_s3_client.aws_secret_access_key, + }, + ) + yield bucket_name + + print(f"\nRemoving S3 bucket {bucket_name}") + aws_s3_client.delete_s3_bucket(bucket_name) + + +@fixture(scope="module") +def ops_manager(namespace: str, s3_bucket: str, custom_version: Optional[str]) -> MongoDBOpsManager: + """The fixture for Ops Manager to be created. Also results in a new s3 bucket + created and used in OM spec""" + om: MongoDBOpsManager = MongoDBOpsManager.from_yaml(yaml_fixture("om_ops_manager_full.yaml"), namespace=namespace) + om.set_version(custom_version) + om["spec"]["backup"]["s3Stores"][0]["s3BucketName"] = s3_bucket + return om.create() + + +@fixture(scope="module") +def oplog_replica_set(ops_manager, custom_mdb_version: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=ops_manager.namespace, + name="my-mongodb-oplog", + ).configure(ops_manager, "development") + resource["spec"]["version"] = custom_mdb_version + resource["spec"]["members"] = 1 + resource["spec"]["persistent"] = True + + yield resource.create() + + +@fixture(scope="module") +def s3_replica_set(ops_manager: MongoDBOpsManager, custom_mdb_version: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=ops_manager.namespace, + name="my-mongodb-s3", + ).configure(ops_manager, "s3metadata") + + resource["spec"]["version"] = custom_mdb_version + resource["spec"]["members"] = 1 + resource["spec"]["persistent"] = True + + yield resource.create() + + +@fixture(scope="module") +def some_mdb(ops_manager: MongoDBOpsManager, custom_mdb_version: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=ops_manager.namespace, + name="some-mdb", + ).configure(ops_manager, "someProject") + resource["spec"]["version"] = custom_mdb_version + resource["spec"]["persistent"] = True + + return resource.create() + + +@fixture(scope="module") +def some_mdb_health_checker(some_mdb: MongoDB) -> MongoDBBackgroundTester: + # TODO increasing allowed_sequential_failures to 5 to remove flakiness until CLOUDP-56877 is solved + return MongoDBBackgroundTester(some_mdb.tester(), allowed_sequential_failures=5) + + +# The first stage of the Operator upgrade test. Create Ops Manager with backup enabled, +# creates backup databases and some extra database referencing the OM. +# TODO CLOUDP-54130: this database needs to get enabled for backup and this needs to be verified +# on the second stage + + +@mark.e2e_operator_upgrade_ops_manager +def test_install_latest_official_operator(official_operator: Operator): + official_operator.assert_is_running() + + +@mark.e2e_operator_upgrade_ops_manager +def test_om_created(ops_manager: MongoDBOpsManager): + ops_manager.backup_status().assert_reaches_phase( + Phase.Pending, + msg_regexp="The MongoDB object .+ doesn't exist", + timeout=900, + ) + + +@mark.e2e_operator_upgrade_ops_manager +def test_backup_enabled( + ops_manager: MongoDBOpsManager, + oplog_replica_set: MongoDB, + s3_replica_set: MongoDB, +): + oplog_replica_set.assert_reaches_phase(Phase.Running) + s3_replica_set.assert_reaches_phase(Phase.Running) + # We are ignoring any errors as there could be temporary blips in connectivity to backing + # databases by this time + ops_manager.backup_status().assert_reaches_phase(Phase.Running, timeout=200, ignore_errors=True) + + +@skip_if_local +@mark.e2e_operator_upgrade_ops_manager +def test_om_is_ok(ops_manager: MongoDBOpsManager): + ops_manager.get_om_tester().assert_healthiness() + + +@mark.e2e_operator_upgrade_ops_manager +def test_mdb_created(some_mdb: MongoDB): + some_mdb.assert_reaches_phase(Phase.Running) + # TODO we need to enable backup for the mongodb - it's critical to make sure the backup for + # deployments continue to work correctly after upgrade + + +# This is a part 2 of the Operator upgrade test. Upgrades the Operator the latest development one and checks +# that everything works + + +@mark.e2e_operator_upgrade_ops_manager +def test_upgrade_operator(default_operator: Operator): + default_operator.assert_is_running() + + +@mark.e2e_operator_upgrade_ops_manager +def test_start_mongod_background_tester( + some_mdb_health_checker: MongoDBBackgroundTester, +): + some_mdb_health_checker.start() + + +@mark.e2e_operator_upgrade_ops_manager +def test_om_ok(ops_manager: MongoDBOpsManager): + # status phases are updated gradually - we need to check for each of them (otherwise "check(Running) for OM" + # will return True right away + ops_manager.appdb_status().assert_reaches_phase(Phase.Reconciling, timeout=100) + # TODO: reduce this timeout, increased from 400 when upgrading from 1 -> 3 container arch. + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=1200) + + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=400) + ops_manager.backup_status().assert_reaches_phase(Phase.Running, timeout=200) + + ops_manager.get_om_tester().assert_healthiness() + + +@mark.e2e_operator_upgrade_ops_manager +def test_some_mdb_ok(some_mdb: MongoDB, some_mdb_health_checker: MongoDBBackgroundTester): + # TODO make sure the backup is working when it's implemented + some_mdb.assert_reaches_phase(Phase.Running, timeout=600, ignore_errors=True) + # The mongodb was supposed to be healthy all the time + some_mdb_health_checker.assert_healthiness() diff --git a/docker/mongodb-enterprise-tests/tests/upgrades/operator_upgrade_replica_set.py b/docker/mongodb-enterprise-tests/tests/upgrades/operator_upgrade_replica_set.py new file mode 100644 index 000000000..68988e63f --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/upgrades/operator_upgrade_replica_set.py @@ -0,0 +1,116 @@ +import pytest +from kubetester import MongoDB +from kubetester.certs import create_mongodb_tls_certs +from kubetester.mongodb import Phase +from kubetester.mongodb_user import MongoDBUser +from kubetester.operator import Operator +from kubetester.kubetester import fixture as yaml_fixture, KubernetesTester +from pytest import fixture + +RS_NAME = "my-replica-set" +USER_PASSWORD = "/qwerty@!#:" +CERT_PREFIX = "prefix" + + +@fixture(scope="module") +def rs_certs_secret(namespace: str, issuer: str): + return create_mongodb_tls_certs( + issuer, namespace, RS_NAME, "{}-{}-cert".format(CERT_PREFIX, RS_NAME) + ) + + +@fixture(scope="module") +def replica_set( + namespace: str, + issuer_ca_configmap: str, + rs_certs_secret: str, + custom_mdb_version: str, +) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set.yaml"), + namespace=namespace, + name=RS_NAME, + ) + resource["spec"]["version"] = custom_mdb_version + + # Make sure we persist in order to be able to upgrade gracefully + # and it is also faster. + resource["spec"]["persistent"] = True + + # TLS + resource.configure_custom_tls( + issuer_ca_configmap, + CERT_PREFIX, + ) + + # SCRAM-SHA + resource["spec"]["security"]["authentication"] = { + "enabled": True, + "modes": ["SCRAM"], + } + + return resource.create() + + +@fixture(scope="module") +def replica_set_user(replica_set: MongoDB) -> MongoDBUser: + """Creates a password secret and then the user referencing it""" + resource = MongoDBUser.from_yaml( + yaml_fixture("scram-sha-user.yaml"), + namespace=replica_set.namespace, + name="rs-user", + ) + resource["spec"]["mongodbResourceRef"]["name"] = replica_set.name + resource["spec"]["passwordSecretKeyRef"]["name"] = "rs-user-password" + resource["spec"]["username"] = "rs-user" + + print( + f"\nCreating password for MongoDBUser {resource.name} in secret/{resource.get_secret_name()} " + ) + KubernetesTester.create_secret( + KubernetesTester.get_namespace(), + resource.get_secret_name(), + { + "password": USER_PASSWORD, + }, + ) + + yield resource.create() + + +@pytest.mark.e2e_operator_upgrade_replica_set +def test_install_latest_official_operator(official_operator: Operator): + official_operator.assert_is_running() + + +@pytest.mark.e2e_operator_upgrade_replica_set +def test_install_replicaset(replica_set: MongoDB): + replica_set.assert_reaches_phase(phase=Phase.Running) + + +@pytest.mark.e2e_operator_upgrade_replica_set +def test_replicaset_user_created(replica_set_user: MongoDBUser): + replica_set_user.assert_reaches_phase(Phase.Updated) + + +@pytest.mark.e2e_operator_upgrade_replica_set +def test_upgrade_operator(default_operator: Operator): + default_operator.assert_is_running() + + +@pytest.mark.e2e_operator_upgrade_replica_set +def test_replicaset_reconciled(replica_set: MongoDB): + replica_set.assert_abandons_phase(phase=Phase.Running, timeout=300) + replica_set.assert_reaches_phase(phase=Phase.Running, timeout=800) + + +@pytest.mark.e2e_operator_upgrade_replica_set +def test_replicaset_connectivity(replica_set: MongoDB, ca_path: str): + tester = replica_set.tester(use_ssl=True, ca_path=ca_path) + tester.assert_connectivity() + + # TODO refactor tester to flexibly test tls + custom CA + scram + # tester.assert_scram_sha_authentication( + # password=USER_PASSWORD, + # username="rs-user", + # auth_mechanism="SCRAM-SHA-256") diff --git a/docker/mongodb-enterprise-tests/tests/users/__init__.py b/docker/mongodb-enterprise-tests/tests/users/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/docker/mongodb-enterprise-tests/tests/users/conftest.py b/docker/mongodb-enterprise-tests/tests/users/conftest.py new file mode 100644 index 000000000..cb0664057 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/users/conftest.py @@ -0,0 +1,4 @@ +def pytest_runtest_setup(item): + """This allows to automatically install the default Operator before running any test""" + if "default_operator" not in item.fixturenames: + item.fixturenames.insert(0, "default_operator") diff --git a/docker/mongodb-enterprise-tests/tests/users/fixtures/user_with_roles.yaml b/docker/mongodb-enterprise-tests/tests/users/fixtures/user_with_roles.yaml new file mode 100644 index 000000000..5322e836f --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/users/fixtures/user_with_roles.yaml @@ -0,0 +1,13 @@ +apiVersion: mongodb.com/v1 +kind: MongoDBUser +metadata: + name: user-with-roles +spec: + username: "CN=mms-user-1,OU=cloud,O=MongoDB,L=New York,ST=New York,C=US" + db: "$external" + opsManager: + configMapRef: + name: my-project + roles: + - db: "admin" + name: "clusterAdmin" diff --git a/docker/mongodb-enterprise-tests/tests/users/fixtures/users_multiple.yaml b/docker/mongodb-enterprise-tests/tests/users/fixtures/users_multiple.yaml new file mode 100644 index 000000000..1c054fdc6 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/users/fixtures/users_multiple.yaml @@ -0,0 +1,73 @@ +--- +- apiVersion: mongodb.com/v1 + kind: MongoDBUser + metadata: + name: mms-user-1 + spec: + username: "CN=mms-user-1,OU=cloud,O=MongoDB,L=New York,ST=New York,C=US" + db: "$external" + mongodbResourceRef: + name: test-x509-rs + roles: + - db: "admin" + name: "clusterAdmin" +- apiVersion: mongodb.com/v1 + kind: MongoDBUser + metadata: + name: mms-user-2 + spec: + username: "CN=mms-user-2,OU=cloud,O=MongoDB,L=New York,ST=New York,C=US" + db: "$external" + mongodbResourceRef: + name: test-x509-rs + roles: + - db: "admin" + name: "clusterManager" +- apiVersion: mongodb.com/v1 + kind: MongoDBUser + metadata: + name: mms-user-3 + spec: + username: "CN=mms-user-3,OU=cloud,O=MongoDB,L=New York,ST=New York,C=US" + db: "$external" + mongodbResourceRef: + name: test-x509-rs + roles: + - db: "admin" + name: "clusterAdmin" +- apiVersion: mongodb.com/v1 + kind: MongoDBUser + metadata: + name: mms-user-4 + spec: + username: "CN=mms-user-4,OU=cloud,O=MongoDB,L=New York,ST=New York,C=US" + db: "$external" + mongodbResourceRef: + name: test-x509-rs + roles: + - db: "admin" + name: "clusterManager" +- apiVersion: mongodb.com/v1 + kind: MongoDBUser + metadata: + name: mms-user-5 + spec: + username: "CN=mms-user-5,OU=cloud,O=MongoDB,L=New York,ST=New York,C=US" + db: "$external" + mongodbResourceRef: + name: test-x509-rs + roles: + - db: "admin" + name: "clusterAdmin" +- apiVersion: mongodb.com/v1 + kind: MongoDBUser + metadata: + name: mms-user-6 + spec: + username: "CN=mms-user-6,OU=cloud,O=MongoDB,L=New York,ST=New York,C=US" + db: "$external" + mongodbResourceRef: + name: test-x509-rs + roles: + - db: "admin" + name: "clusterManager" diff --git a/docker/mongodb-enterprise-tests/tests/users/users_addition_removal.py b/docker/mongodb-enterprise-tests/tests/users/users_addition_removal.py new file mode 100644 index 000000000..d1ef7c0e4 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/users/users_addition_removal.py @@ -0,0 +1,131 @@ +import pytest + +from cryptography import x509 +from cryptography.x509.oid import NameOID +from cryptography.hazmat.backends import default_backend + +from kubetester.mongodb import MongoDB, Phase +from kubetester import find_fixture +from kubetester.kubetester import KubernetesTester, fixture as load_fixture +from kubetester.certs import ( + Certificate, + ISSUER_CA_NAME, + create_mongodb_tls_certs, + create_agent_tls_certs, +) + +MDB_RESOURCE = "test-x509-rs" +NUM_AGENTS = 2 + + +def get_subjects(start, end): + return [ + f"CN=mms-user-{i},OU=cloud,O=MongoDB,L=New York,ST=New York,C=US" + for i in range(start, end) + ] + + +@pytest.fixture(scope="module") +def server_certs(issuer: str, namespace: str): + return create_mongodb_tls_certs( + ISSUER_CA_NAME, namespace, MDB_RESOURCE, f"{MDB_RESOURCE}-cert" + ) + + +def get_names_from_certificate_attributes(cert): + names = {} + subject = cert.subject + names["OU"] = subject.get_attributes_for_oid(NameOID.ORGANIZATIONAL_UNIT_NAME)[ + 0 + ].value + names["CN"] = subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value + + return names + + +@pytest.fixture(scope="module") +def agent_certs(issuer: str, namespace: str) -> str: + return create_agent_tls_certs(issuer, namespace, MDB_RESOURCE) + + +@pytest.fixture(scope="module") +def mdb( + namespace: str, server_certs: str, agent_certs: str, issuer_ca_configmap: str +) -> MongoDB: + res = MongoDB.from_yaml(load_fixture("test-x509-rs.yaml"), namespace=namespace) + res["spec"]["security"]["tls"]["ca"] = issuer_ca_configmap + return res.create() + + +@pytest.mark.e2e_tls_x509_users_addition_removal +class TestReplicaSetUpgradeToTLSWithX509Project(KubernetesTester): + def test_mdb_resource_running(self, mdb: MongoDB): + mdb.assert_reaches_phase(Phase.Running, timeout=300) + + def test_certificates_have_sane_subject(self, namespace): + agent_certs = KubernetesTester.read_secret(namespace, "agent-certs-pem") + + bytecert = bytes(agent_certs["mms-automation-agent-pem"], "utf-8") + cert = x509.load_pem_x509_certificate(bytecert, default_backend()) + names = get_names_from_certificate_attributes(cert) + + assert names["CN"] == "mms-automation-agent" + assert names["OU"] == namespace + + +@pytest.mark.e2e_tls_x509_users_addition_removal +class TestMultipleUsersAreAdded(KubernetesTester): + """ + name: Test users are added correctly + create_many: + file: users_multiple.yaml + wait_until: all_users_ready + """ + + def test_users_ready(self): + pass + + @staticmethod + def all_users_ready(): + ac = KubernetesTester.get_automation_config() + return len(ac["auth"]["usersWanted"]) == 6 # 6 MongoDBUsers + + def test_users_are_added_to_automation_config(self): + ac = KubernetesTester.get_automation_config() + existing_users = sorted( + ac["auth"]["usersWanted"], key=lambda user: user["user"] + ) + expected_users = sorted(get_subjects(1, 7)) + existing_subjects = [u["user"] for u in ac["auth"]["usersWanted"]] + + for expected in expected_users: + assert expected in existing_subjects + + +@pytest.mark.e2e_tls_x509_users_addition_removal +class TestTheCorrectUserIsDeleted(KubernetesTester): + """ + delete: + delete_name: mms-user-4 + file: users_multiple.yaml + wait_until: user_has_been_deleted + """ + + @staticmethod + def user_has_been_deleted(): + ac = KubernetesTester.get_automation_config() + return len(ac["auth"]["usersWanted"]) == 5 # One user has been deleted + + def test_deleted_user_is_gone(self): + ac = KubernetesTester.get_automation_config() + users = ac["auth"]["usersWanted"] + assert "CN=mms-user-4,OU=cloud,O=MongoDB,L=New York,ST=New York,C=US" not in [ + user["user"] for user in users + ] + + +def get_user_pkix_names(ac, agent_name: str) -> str: + subject = [u["user"] for u in ac["auth"]["usersWanted"] if agent_name in u["user"]][ + 0 + ] + return dict(name.split("=") for name in subject.split(",")) diff --git a/docker/mongodb-enterprise-tests/tests/users/users_schema_validation.py b/docker/mongodb-enterprise-tests/tests/users/users_schema_validation.py new file mode 100644 index 000000000..9384665ac --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/users/users_schema_validation.py @@ -0,0 +1,44 @@ +import pytest + +from kubetester.kubetester import KubernetesTester + + +class TestUsersSchemaValidationUserName(KubernetesTester): + """ + name: Validation for mongodbusers (username) + create: + file: user_with_roles.yaml + patch: '[{"op":"remove","path":"/spec/username"}]' + exception: 'Unprocessable Entity' + """ + + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_users_schema_validation +class TestUsersSchemaValidationRoleName(KubernetesTester): + """ + name: Validation for mongodbusers (role name) + create: + file: user_with_roles.yaml + patch: '[{"op":"remove","path":"/spec/roles/0/name"}]' + exception: 'Unprocessable Entity' + """ + + def test_validation_ok(self): + assert True + + +@pytest.mark.e2e_users_schema_validation +class TestUsersSchemaValidationRoleDb(KubernetesTester): + """ + name: Validation for mongodbusers (role db) + create: + file: user_with_roles.yaml + patch: '[{"op":"remove","path":"/spec/roles/0/db"}]' + exception: 'Unprocessable Entity' + """ + + def test_validation_ok(self): + assert True diff --git a/docker/mongodb-enterprise-tests/tests/vaultintegration/__init__.py b/docker/mongodb-enterprise-tests/tests/vaultintegration/__init__.py new file mode 100644 index 000000000..5cbaf121b --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/vaultintegration/__init__.py @@ -0,0 +1,64 @@ +from typing import Optional, List, Dict +from kubetester.kubetester import KubernetesTester + + +def run_command_in_vault( + vault_namespace: str, + vault_name: str, + cmd: List[str], + expected_message: Optional[List[str]] = None, +) -> str: + result = KubernetesTester.run_command_in_pod_container( + pod_name=f"{vault_name}-0", + namespace=vault_namespace, + cmd=cmd, + container="vault", + ) + + if expected_message is None: + expected_message = ["Success!"] + + if not expected_message: + return result + + for message in expected_message: + if message in result: + return result + + raise ValueError(f"Command failed. Got {result}") + + +def vault_sts_name() -> str: + return "vault" + + +def vault_namespace_name() -> str: + return "vault" + + +def store_secret_in_vault( + vault_namespace: str, vault_name: str, secretData: Dict[str, str], path: str +): + cmd = ["vault", "kv", "put", path] + + for k, v in secretData.items(): + cmd.append(f"{k}={v}") + + run_command_in_vault( + vault_namespace, + vault_name, + cmd, + expected_message=["created_time"], + ) + + +def assert_secret_in_vault( + vault_namespace: str, vault_name: str, path: str, expected_message: List[str] +): + cmd = [ + "vault", + "kv", + "get", + path, + ] + run_command_in_vault(vault_namespace, vault_name, cmd, expected_message) diff --git a/docker/mongodb-enterprise-tests/tests/vaultintegration/conftest.py b/docker/mongodb-enterprise-tests/tests/vaultintegration/conftest.py new file mode 100644 index 000000000..3a8f21eda --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/vaultintegration/conftest.py @@ -0,0 +1,162 @@ +from pytest import fixture + +from kubetester.helm import helm_install_from_chart +from kubetester import get_pod_when_ready, get_pod_when_running +from kubetester.kubetester import KubernetesTester +from kubetester.certs import create_vault_certs +import time +from . import vault_sts_name, vault_namespace_name, run_command_in_vault +from kubernetes.client.rest import ApiException +from kubernetes import client + + +@fixture(scope="module") +def vault(namespace: str, version="v0.17.1", name="vault") -> str: + + try: + KubernetesTester.create_namespace(name) + except ApiException as e: + pass + helm_install_from_chart( + namespace=name, + release=name, + chart=f"hashicorp/vault", + version=version, + custom_repo=("hashicorp", "https://helm.releases.hashicorp.com"), + ) + + # check if vault pod is running + # We need to perform the initialization of the vault in order to the pod + # to be ready. But we need to wait for it to be running in order to run commands in it. + get_pod_when_running( + namespace=name, + label_selector=f"app.kubernetes.io/instance={name},app.kubernetes.io/name={name}", + ) + perform_vault_initialization(name, name) + + # check if vault pod is ready + get_pod_when_ready( + namespace=name, + label_selector=f"app.kubernetes.io/instance={name},app.kubernetes.io/name={name}", + ) + + # check if vault agent injector pod is ready + get_pod_when_ready( + namespace=name, + label_selector=f"app.kubernetes.io/instance={name},app.kubernetes.io/name=vault-agent-injector", + ) + + return name + + +@fixture(scope="module") +def vault_tls( + namespace: str, issuer: str, vault_namespace: str, version="v0.17.1", name="vault" +) -> str: + + try: + KubernetesTester.create_namespace(vault_namespace) + except ApiException as e: + pass + create_vault_certs(namespace, issuer, vault_namespace, name, "vault-tls") + + helm_install_from_chart( + namespace=name, + release=name, + chart=f"hashicorp/vault", + version=version, + custom_repo=("hashicorp", "https://helm.releases.hashicorp.com"), + override_path="/tests/vaultconfig/override.yaml", + ) + + # check if vault pod is running + get_pod_when_running( + namespace=name, + label_selector=f"app.kubernetes.io/instance={name},app.kubernetes.io/name={name}", + ) + perform_vault_initialization(name, name) + + # check if vault pod is ready + get_pod_when_ready( + namespace=name, + label_selector=f"app.kubernetes.io/instance={name},app.kubernetes.io/name={name}", + ) + + # check if vault agent injector pod is ready + get_pod_when_ready( + namespace=name, + label_selector=f"app.kubernetes.io/instance={name},app.kubernetes.io/name=vault-agent-injector", + ) + + +def perform_vault_initialization(namespace: str, name: str): + + # If the pod is already ready, this means we don't have to perform anything + pods = client.CoreV1Api().list_namespaced_pod( + namespace, + label_selector=f"app.kubernetes.io/instance={name},app.kubernetes.io/name={name}", + ) + + pod = pods.items[0] + if pod.status.conditions is not None: + for condition in pod.status.conditions: + if condition.type == "Ready" and condition.status == "True": + return + + run_command_in_vault(name, name, ["mkdir", "-p", "/vault/data"], []) + + response = run_command_in_vault( + name, name, ["vault", "operator", "init"], ["Unseal"] + ) + + response = response.split("\n") + unseal_keys = [] + for i in range(5): + unseal_keys.append(response[i].split(": ")[1]) + + for i in range(3): + run_command_in_vault( + name, name, ["vault", "operator", "unseal", unseal_keys[i]], [] + ) + + token = response[6].split(": ")[1] + run_command_in_vault(name, name, ["vault", "login", token]) + + run_command_in_vault( + name, name, ["vault", "secrets", "enable", "-path=secret/", "kv-v2"] + ) + + +@fixture(scope="module") +def vault_url(vault_name: str, vault_namespace: str) -> str: + return f"{vault_name}.{vault_namespace}.svc.cluster.local" + + +@fixture(scope="module") +def vault_namespace() -> str: + return vault_namespace_name() + + +@fixture(scope="module") +def vault_operator_policy_name() -> str: + return "mongodbenterprise" + + +@fixture(scope="module") +def vault_name() -> str: + return vault_sts_name() + + +@fixture(scope="module") +def vault_database_policy_name() -> str: + return "mongodbenterprisedatabase" + + +@fixture(scope="module") +def vault_appdb_policy_name() -> str: + return "mongodbenterpriseappdb" + + +@fixture(scope="module") +def vault_om_policy_name() -> str: + return "mongodbenterpriseopsmanager" diff --git a/docker/mongodb-enterprise-tests/tests/vaultintegration/mongodb_deployment_vault.py b/docker/mongodb-enterprise-tests/tests/vaultintegration/mongodb_deployment_vault.py new file mode 100644 index 000000000..b1c2fea59 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/vaultintegration/mongodb_deployment_vault.py @@ -0,0 +1,475 @@ +import uuid + +import pytest +import time +from kubernetes import client +from kubernetes.client.rest import ApiException +from kubetester import ( + MongoDB, + create_configmap, + delete_secret, + get_statefulset, + random_k8s_name, + read_secret, +) +from kubetester.certs import ( + create_mongodb_tls_certs, + create_x509_agent_tls_certs, + create_x509_mongodb_tls_certs, +) +from kubetester.http import https_endpoint_is_reachable +from kubetester.kubetester import KubernetesTester +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.mongodb import Phase, get_pods +from kubetester.mongodb_user import MongoDBUser +from kubetester.operator import Operator +from pytest import fixture, mark + +from . import run_command_in_vault, store_secret_in_vault + +OPERATOR_NAME = "mongodb-enterprise-operator" +MDB_RESOURCE = "my-replica-set" +DATABASE_SA_NAME = "mongodb-enterprise-database-pods" +USER_NAME = "my-user-1" +PASSWORD_SECRET_NAME = "mms-user-1-password" +USER_PASSWORD = "my-password" + + +def certs_for_prometheus(issuer: str, namespace: str, resource_name: str) -> str: + secret_name = random_k8s_name(resource_name + "-") + "-prometheus-cert" + + return create_mongodb_tls_certs( + issuer, + namespace, + resource_name, + secret_name, + secret_backend="Vault", + vault_subpath="database", + ) + + +@fixture(scope="module") +def replica_set( + namespace: str, + custom_mdb_version: str, + server_certs: str, + agent_certs: str, + clusterfile_certs: str, + issuer_ca_configmap: str, + issuer: str, + vault_namespace: str, + vault_name: str, +) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set.yaml"), MDB_RESOURCE, namespace + ) + resource.set_version(custom_mdb_version) + resource["spec"]["security"] = { + "tls": {"enabled": True, "ca": issuer_ca_configmap}, + "authentication": { + "enabled": True, + "modes": ["X509", "SCRAM"], + "agents": {"mode": "X509"}, + "internalCluster": "X509", + }, + } + + prom_cert_secret = certs_for_prometheus(issuer, namespace, resource.name) + store_secret_in_vault( + vault_namespace, + vault_name, + {"password": "prom-password"}, + f"secret/mongodbenterprise/operator/{namespace}/prom-password", + ) + resource["spec"]["prometheus"] = { + "username": "prom-user", + "passwordSecretRef": { + "name": "prom-password", + }, + "tlsSecretKeyRef": { + "name": prom_cert_secret, + }, + } + resource.create() + + return resource + + +@fixture(scope="module") +def agent_certs(issuer: str, namespace: str) -> str: + return create_x509_agent_tls_certs( + issuer, namespace, MDB_RESOURCE, secret_backend="Vault" + ) + + +@fixture(scope="module") +def server_certs( + vault_namespace: str, vault_name: str, namespace: str, issuer: str +) -> str: + create_x509_mongodb_tls_certs( + issuer, + namespace, + MDB_RESOURCE, + f"{MDB_RESOURCE}-cert", + secret_backend="Vault", + vault_subpath="database", + ) + + +@fixture(scope="module") +def clusterfile_certs( + vault_namespace: str, vault_name: str, namespace: str, issuer: str +) -> str: + create_x509_mongodb_tls_certs( + issuer, + namespace, + MDB_RESOURCE, + f"{MDB_RESOURCE}-clusterfile", + secret_backend="Vault", + vault_subpath="database", + ) + + +@fixture(scope="module") +def sharded_cluster_configmap(namespace: str) -> str: + cm = KubernetesTester.read_configmap(namespace, "my-project") + epoch_time = int(time.time()) + project_name = "sharded-" + str(epoch_time) + "-" + uuid.uuid4().hex[0:10] + data = { + "baseUrl": cm["baseUrl"], + "projectName": project_name, + "orgId": cm["orgId"], + } + create_configmap(namespace=namespace, name=project_name, data=data) + return project_name + + +@fixture(scope="module") +def sharded_cluster( + namespace: str, + sharded_cluster_configmap: str, + issuer: str, + vault_namespace: str, + vault_name: str, +) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("sharded-cluster.yaml"), namespace=namespace + ) + resource["spec"]["cloudManager"]["configMapRef"]["name"] = sharded_cluster_configmap + + # Password stored in Prometheus + store_secret_in_vault( + vault_namespace, + vault_name, + {"password": "prom-password"}, + f"secret/mongodbenterprise/operator/{namespace}/prom-password-cluster", + ) + + # A prometheus certificate stored in Vault + prom_cert_secret = certs_for_prometheus(issuer, namespace, resource.name) + resource["spec"]["prometheus"] = { + "username": "prom-user", + "passwordSecretRef": { + "name": "prom-password-cluster", + }, + "tlsSecretKeyRef": { + "name": prom_cert_secret, + }, + } + + return resource.create() + + +@fixture(scope="module") +def mongodb_user(namespace: str) -> MongoDBUser: + resource = MongoDBUser.from_yaml( + yaml_fixture("mongodb-user.yaml"), "vault-replica-set-scram-user", namespace + ) + + resource["spec"]["username"] = USER_NAME + resource["spec"]["passwordSecretKeyRef"] = { + "name": PASSWORD_SECRET_NAME, + "key": "password", + } + + resource["spec"]["mongodbResourceRef"]["name"] = MDB_RESOURCE + return resource.create() + + +@mark.e2e_vault_setup +def test_vault_creation(vault: str, vault_name: str, vault_namespace: str): + vault + + # assert if vault statefulset is ready, this is sort of redundant(we already assert for pod phase) + # but this is basic assertion at the moment, will remove in followup PR + sts = get_statefulset(namespace=vault_namespace, name=vault_name) + assert sts.status.ready_replicas == 1 + + +@mark.e2e_vault_setup +def test_create_vault_operator_policy(vault_name: str, vault_namespace: str): + # copy hcl file from local machine to pod + KubernetesTester.copy_file_inside_pod( + f"{vault_name}-0", + "vaultpolicies/operator-policy.hcl", + "/tmp/operator-policy.hcl", + namespace=vault_namespace, + ) + + cmd = [ + "vault", + "policy", + "write", + "mongodbenterprise", + "/tmp/operator-policy.hcl", + ] + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup +def test_enable_kubernetes_auth(vault_name: str, vault_namespace: str): + # enable Kubernetes auth for Vault + cmd = [ + "vault", + "auth", + "enable", + "kubernetes", + ] + + run_command_in_vault( + vault_namespace, + vault_name, + cmd, + expected_message=["Success!", "path is already in use at kubernetes"], + ) + + cmd = [ + "cat", + "/var/run/secrets/kubernetes.io/serviceaccount/token", + ] + + token = run_command_in_vault(vault_namespace, vault_name, cmd, expected_message=[]) + + cmd = ["env"] + + response = run_command_in_vault( + vault_namespace, vault_name, cmd, expected_message=[] + ) + + response = response.split("\n") + for line in response: + l = line.strip() + if str.startswith(l, "KUBERNETES_PORT_443_TCP_ADDR"): + cluster_ip = l.split("=")[1] + break + + cmd = [ + "vault", + "write", + "auth/kubernetes/config", + f"token_reviewer_jwt={token}", + f"kubernetes_host=https://{cluster_ip}:443", + "kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt", + "disable_iss_validation=true", + ] + + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup +def test_enable_vault_role_for_operator_pod( + vault_name: str, + vault_namespace: str, + namespace: str, + vault_operator_policy_name: str, +): + cmd = [ + "vault", + "write", + "auth/kubernetes/role/mongodbenterprise", + f"bound_service_account_names={OPERATOR_NAME}", + f"bound_service_account_namespaces={namespace}", + f"policies={vault_operator_policy_name}", + ] + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup +def test_operator_install_with_vault_backend(operator_vault_secret_backend: Operator): + operator_vault_secret_backend.assert_is_running() + + +@mark.e2e_vault_setup +def test_vault_config_map_exists(namespace: str): + # no exception should be raised + KubernetesTester.read_configmap(namespace, name="secret-configuration") + + +@mark.e2e_vault_setup +def test_store_om_credentials_in_vault( + vault_namespace: str, vault_name: str, namespace: str +): + credentials = read_secret(namespace, "my-credentials") + store_secret_in_vault( + vault_namespace, + vault_name, + credentials, + f"secret/mongodbenterprise/operator/{namespace}/my-credentials", + ) + + cmd = [ + "vault", + "kv", + "get", + f"secret/mongodbenterprise/operator/{namespace}/my-credentials", + ] + run_command_in_vault( + vault_namespace, vault_name, cmd, expected_message=["publicApiKey"] + ) + delete_secret(namespace, "my-credentials") + + +@mark.e2e_vault_setup +def test_create_database_policy(vault_name: str, vault_namespace: str): + KubernetesTester.copy_file_inside_pod( + f"{vault_name}-0", + "vaultpolicies/database-policy.hcl", + "/tmp/database-policy.hcl", + namespace=vault_namespace, + ) + + cmd = [ + "vault", + "policy", + "write", + "mongodbenterprisedatabase", + "/tmp/database-policy.hcl", + ] + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup +def test_enable_vault_role_for_database_pod( + vault_name: str, + vault_namespace: str, + namespace: str, + vault_database_policy_name: str, +): + cmd = [ + "vault", + "write", + f"auth/kubernetes/role/{vault_database_policy_name}", + f"bound_service_account_names={DATABASE_SA_NAME}", + f"bound_service_account_namespaces={namespace}", + f"policies={vault_database_policy_name}", + ] + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup +def test_mdb_created(replica_set: MongoDB, namespace: str): + replica_set.assert_reaches_phase(Phase.Running, timeout=500, ignore_errors=True) + for pod_name in get_pods(MDB_RESOURCE + "-{}", 3): + pod = client.CoreV1Api().read_namespaced_pod(pod_name, namespace) + assert len(pod.spec.containers) == 2 + + +@mark.e2e_vault_setup +def test_rotate_agent_certs( + replica_set: MongoDB, vault_namespace: str, vault_name: str, namespace: str +): + replica_set.load() + old_version = replica_set["metadata"]["annotations"]["agent-certs"] + cmd = [ + "vault", + "kv", + "patch", + f"secret/mongodbenterprise/database/{namespace}/agent-certs", + "foo=bar", + ] + run_command_in_vault(vault_namespace, vault_name, cmd, ["version"]) + replica_set.assert_reaches_phase(Phase.Reconciling, timeout=100) + replica_set.assert_reaches_phase(Phase.Running, timeout=500, ignore_errors=True) + replica_set.load() + assert old_version != replica_set["metadata"]["annotations"]["agent-certs"] + + +@mark.e2e_vault_setup +def test_no_certs_in_kubernetes(namespace: str): + with pytest.raises(ApiException): + read_secret(namespace, f"{MDB_RESOURCE}-clusterfile") + with pytest.raises(ApiException): + read_secret(namespace, f"{MDB_RESOURCE}-cert") + with pytest.raises(ApiException): + read_secret(namespace, "agent-certs") + + +@mark.e2e_vault_setup +def test_api_key_in_pod(replica_set: MongoDB): + cmd = ["cat", "/mongodb-automation/agent-api-key/agentApiKey"] + + result = KubernetesTester.run_command_in_pod_container( + pod_name=f"{replica_set.name}-0", + namespace=replica_set.namespace, + cmd=cmd, + container="mongodb-enterprise-database", + ) + + assert result != "" + + +@mark.e2e_vault_setup +def test_prometheus_endpoint_on_replica_set(replica_set: MongoDB, namespace: str): + members = replica_set["spec"]["members"] + name = replica_set.name + + auth = ("prom-user", "prom-password") + + for idx in range(members): + member_url = f"https://{name}-{idx}.{name}-svc.{namespace}.svc.cluster.local:9216/metrics" + assert https_endpoint_is_reachable(member_url, auth, tls_verify=False) + + +@mark.e2e_vault_setup +def test_sharded_mdb_created(sharded_cluster: MongoDB): + sharded_cluster.assert_reaches_phase(Phase.Running, timeout=600, ignore_errors=True) + + +@mark.e2e_vault_setup +def test_prometheus_endpoint_works_on_every_pod_on_the_cluster( + sharded_cluster: MongoDB, namespace: str +): + auth = ("prom-user", "prom-password") + name = sharded_cluster.name + + mongos_count = sharded_cluster["spec"]["mongosCount"] + for idx in range(mongos_count): + url = f"https://{name}-mongos-{idx}.{name}-svc.{namespace}.svc.cluster.local:9216/metrics" + assert https_endpoint_is_reachable(url, auth, tls_verify=False) + + shard_count = sharded_cluster["spec"]["shardCount"] + mongodbs_per_shard_count = sharded_cluster["spec"]["mongodsPerShardCount"] + for shard in range(shard_count): + for mongodb in range(mongodbs_per_shard_count): + url = f"https://{name}-{shard}-{mongodb}.{name}-sh.{namespace}.svc.cluster.local:9216/metrics" + assert https_endpoint_is_reachable(url, auth, tls_verify=False) + + config_server_count = sharded_cluster["spec"]["configServerCount"] + for idx in range(config_server_count): + url = f"https://{name}-config-{idx}.{name}-cs.{namespace}.svc.cluster.local:9216/metrics" + assert https_endpoint_is_reachable(url, auth, tls_verify=False) + + +@mark.e2e_vault_setup +def test_create_mongodb_user( + mongodb_user: MongoDBUser, vault_name: str, vault_namespace: str, namespace: str +): + data = {"password": USER_PASSWORD} + store_secret_in_vault( + vault_namespace, + vault_name, + data, + f"secret/mongodbenterprise/database/{namespace}/{PASSWORD_SECRET_NAME}", + ) + + mongodb_user.assert_reaches_phase(Phase.Updated, timeout=100) diff --git a/docker/mongodb-enterprise-tests/tests/vaultintegration/om_backup_vault.py b/docker/mongodb-enterprise-tests/tests/vaultintegration/om_backup_vault.py new file mode 100644 index 000000000..aebe2e663 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/vaultintegration/om_backup_vault.py @@ -0,0 +1,477 @@ +from kubetester.opsmanager import MongoDBOpsManager +from typing import Optional, Dict +from pytest import fixture, mark +import pytest +from kubetester.operator import Operator +from . import run_command_in_vault, store_secret_in_vault, assert_secret_in_vault +from kubetester import ( + get_statefulset, + create_secret, + delete_secret, + create_configmap, + read_secret, + random_k8s_name, +) +from kubetester.http import https_endpoint_is_reachable +from kubetester.awss3client import AwsS3Client, s3_endpoint +from kubetester import get_default_storage_class +from kubetester.mongodb import Phase, MongoDB +from kubetester.certs import create_ops_manager_tls_certs, create_mongodb_tls_certs +from kubetester.kubetester import KubernetesTester, fixture as yaml_fixture +from kubetester.mongodb import Phase, get_pods +from kubernetes.client.rest import ApiException +from kubernetes import client + +OPERATOR_NAME = "mongodb-enterprise-operator" +APPDB_SA_NAME = "mongodb-enterprise-appdb" +OM_SA_NAME = "mongodb-enterprise-ops-manager" +OM_NAME = "om-basic" +S3_RS_NAME = "my-mongodb-s3" +S3_SECRET_NAME = "my-s3-secret" +OPLOG_RS_NAME = "my-mongodb-oplog" +AWS_REGION = "us-east-1" + +DATABASE_SA_NAME = "mongodb-enterprise-database-pods" + + +def certs_for_prometheus(issuer: str, namespace: str, resource_name: str) -> str: + secret_name = random_k8s_name(resource_name + "-") + "-prometheus-cert" + + return create_mongodb_tls_certs( + issuer, + namespace, + resource_name, + secret_name, + secret_backend="Vault", + vault_subpath="appdb", + ) + + +def new_om_s3_store( + mdb: MongoDB, + s3_id: str, + s3_bucket_name: str, + aws_s3_client: AwsS3Client, + assignment_enabled: bool = True, + path_style_access_enabled: bool = True, + user_name: Optional[str] = None, + password: Optional[str] = None, +) -> Dict: + return { + "uri": mdb.mongo_uri(user_name=user_name, password=password), + "id": s3_id, + "pathStyleAccessEnabled": path_style_access_enabled, + "s3BucketEndpoint": s3_endpoint(AWS_REGION), + "s3BucketName": s3_bucket_name, + "awsAccessKey": aws_s3_client.aws_access_key, + "awsSecretKey": aws_s3_client.aws_secret_access_key, + "assignmentEnabled": assignment_enabled, + } + + +@fixture(scope="module") +def s3_bucket( + aws_s3_client: AwsS3Client, namespace: str, vault_namespace: str, vault_name: str +) -> str: + create_aws_secret( + aws_s3_client, S3_SECRET_NAME, vault_namespace, vault_name, namespace + ) + yield from create_s3_bucket(aws_s3_client) + + +def create_aws_secret( + aws_s3_client, + secret_name: str, + vault_namespace: str, + vault_name: str, + namespace: str, +): + data = { + "accessKey": aws_s3_client.aws_access_key, + "secretKey": aws_s3_client.aws_secret_access_key, + } + path = f"secret/mongodbenterprise/operator/{namespace}/{secret_name}" + store_secret_in_vault(vault_namespace, vault_name, data, path) + + +def create_s3_bucket(aws_s3_client, bucket_prefix: str = "test-bucket-"): + """creates a s3 bucket and a s3 config""" + bucket_prefix = KubernetesTester.random_k8s_name(bucket_prefix) + aws_s3_client.create_s3_bucket(bucket_prefix) + print("Created S3 bucket", bucket_prefix) + + yield bucket_prefix + print("\nRemoving S3 bucket", bucket_prefix) + aws_s3_client.delete_s3_bucket(bucket_prefix) + + +@fixture(scope="module") +def ops_manager( + namespace: str, + custom_version: Optional[str], + custom_appdb_version: str, + s3_bucket: str, + issuer_ca_configmap: str, + issuer: str, + vault_namespace: str, + vault_name: str, +) -> MongoDBOpsManager: + om = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_basic.yaml"), namespace=namespace + ) + + om["spec"]["backup"] = { + "enabled": True, + "s3Stores": [ + { + "name": "s3Store1", + "s3BucketName": s3_bucket, + "mongodbResourceRef": {"name": "my-mongodb-s3"}, + "s3SecretRef": {"name": S3_SECRET_NAME}, + "pathStyleAccessEnabled": True, + "s3BucketEndpoint": "s3.us-east-1.amazonaws.com", + }, + ], + "headDB": { + "storage": "500M", + "storageClass": get_default_storage_class(), + }, + "opLogStores": [ + { + "name": "oplog1", + "mongodbResourceRef": {"name": "my-mongodb-oplog"}, + }, + ], + } + om.set_version(custom_version) + om.set_appdb_version(custom_appdb_version) + + prom_cert_secret = certs_for_prometheus(issuer, namespace, om.name + "-db") + store_secret_in_vault( + vault_namespace, + vault_name, + {"password": "prom-password"}, + f"secret/mongodbenterprise/operator/{namespace}/prom-password", + ) + + om["spec"]["applicationDatabase"]["prometheus"] = { + "username": "prom-user", + "passwordSecretRef": {"name": "prom-password"}, + "tlsSecretKeyRef": { + "name": prom_cert_secret, + }, + } + + return om.create() + + +@fixture(scope="module") +def oplog_replica_set(ops_manager, namespace) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=OPLOG_RS_NAME, + ).configure(ops_manager, "development") + + resource["spec"]["version"] = "4.4.0" + + yield resource.create() + + +@fixture(scope="module") +def s3_replica_set(ops_manager, namespace) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("replica-set-for-om.yaml"), + namespace=namespace, + name=S3_RS_NAME, + ).configure(ops_manager, "s3metadata") + + resource["spec"]["version"] = "4.4.0" + yield resource.create() + + +@mark.e2e_vault_setup_om_backup +def test_vault_creation(vault: str, vault_name: str, vault_namespace: str): + vault + sts = get_statefulset(namespace=vault_namespace, name=vault_name) + assert sts.status.ready_replicas == 1 + + +@mark.e2e_vault_setup_om_backup +def test_create_vault_operator_policy(vault_name: str, vault_namespace: str): + # copy hcl file from local machine to pod + KubernetesTester.copy_file_inside_pod( + f"{vault_name}-0", + "vaultpolicies/operator-policy.hcl", + "/tmp/operator-policy.hcl", + namespace=vault_namespace, + ) + + cmd = [ + "vault", + "policy", + "write", + "mongodbenterprise", + "/tmp/operator-policy.hcl", + ] + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup_om_backup +def test_enable_kubernetes_auth(vault_name: str, vault_namespace: str): + cmd = [ + "vault", + "auth", + "enable", + "kubernetes", + ] + + run_command_in_vault( + vault_namespace, + vault_name, + cmd, + expected_message=["Success!", "path is already in use at kubernetes"], + ) + + cmd = [ + "cat", + "/var/run/secrets/kubernetes.io/serviceaccount/token", + ] + + token = run_command_in_vault(vault_namespace, vault_name, cmd, expected_message=[]) + cmd = ["env"] + + response = run_command_in_vault( + vault_namespace, vault_name, cmd, expected_message=[] + ) + + response = response.split("\n") + for line in response: + l = line.strip() + if str.startswith(l, "KUBERNETES_PORT_443_TCP_ADDR"): + cluster_ip = l.split("=")[1] + break + cmd = [ + "vault", + "write", + "auth/kubernetes/config", + f"token_reviewer_jwt={token}", + f"kubernetes_host=https://{cluster_ip}:443", + "kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt", + "disable_iss_validation=true", + ] + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup_om_backup +def test_enable_vault_role_for_operator_pod( + vault_name: str, + vault_namespace: str, + namespace: str, + vault_operator_policy_name: str, +): + cmd = [ + "vault", + "write", + "auth/kubernetes/role/mongodbenterprise", + f"bound_service_account_names={OPERATOR_NAME}", + f"bound_service_account_namespaces={namespace}", + f"policies={vault_operator_policy_name}", + ] + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup_om_backup +def test_put_admin_credentials_to_vault( + namespace: str, vault_namespace: str, vault_name: str +): + admin_credentials_secret_name = "ops-manager-admin-secret" + # read the -admin-secret from namespace and store in vault + data = read_secret(namespace, admin_credentials_secret_name) + path = ( + f"secret/mongodbenterprise/operator/{namespace}/{admin_credentials_secret_name}" + ) + store_secret_in_vault(vault_namespace, vault_name, data, path) + delete_secret(namespace, admin_credentials_secret_name) + + +@mark.e2e_vault_setup_om_backup +def test_operator_install_with_vault_backend(operator_vault_secret_backend: Operator): + operator_vault_secret_backend.assert_is_running() + + +@mark.e2e_vault_setup_om_backup +def test_create_appdb_policy(vault_name: str, vault_namespace: str): + KubernetesTester.copy_file_inside_pod( + f"{vault_name}-0", + "vaultpolicies/appdb-policy.hcl", + "/tmp/appdb-policy.hcl", + namespace=vault_namespace, + ) + + cmd = [ + "vault", + "policy", + "write", + "mongodbenterpriseappdb", + "/tmp/appdb-policy.hcl", + ] + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup_om_backup +def test_create_om_policy(vault_name: str, vault_namespace: str): + KubernetesTester.copy_file_inside_pod( + f"{vault_name}-0", + "vaultpolicies/opsmanager-policy.hcl", + "/tmp/om-policy.hcl", + namespace=vault_namespace, + ) + + cmd = [ + "vault", + "policy", + "write", + "mongodbenterpriseopsmanager", + "/tmp/om-policy.hcl", + ] + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup_om_backup +def test_enable_vault_role_for_appdb_pod( + vault_name: str, + vault_namespace: str, + namespace: str, + vault_appdb_policy_name: str, +): + cmd = [ + "vault", + "write", + f"auth/kubernetes/role/{vault_appdb_policy_name}", + f"bound_service_account_names={APPDB_SA_NAME}", + f"bound_service_account_namespaces={namespace}", + f"policies={vault_appdb_policy_name}", + ] + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup_om_backup +def test_enable_vault_role_for_om_pod( + vault_name: str, + vault_namespace: str, + namespace: str, + vault_om_policy_name: str, +): + cmd = [ + "vault", + "write", + f"auth/kubernetes/role/{vault_om_policy_name}", + f"bound_service_account_names={OM_SA_NAME}", + f"bound_service_account_namespaces={namespace}", + f"policies={vault_om_policy_name}", + ] + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup_om_backup +def test_create_database_policy(vault_name: str, vault_namespace: str): + KubernetesTester.copy_file_inside_pod( + f"{vault_name}-0", + "vaultpolicies/database-policy.hcl", + "/tmp/database-policy.hcl", + namespace=vault_namespace, + ) + + cmd = [ + "vault", + "policy", + "write", + "mongodbenterprisedatabase", + "/tmp/database-policy.hcl", + ] + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup_om_backup +def test_enable_vault_role_for_database_pod( + vault_name: str, + vault_namespace: str, + namespace: str, + vault_database_policy_name: str, +): + cmd = [ + "vault", + "write", + f"auth/kubernetes/role/{vault_database_policy_name}", + f"bound_service_account_names={DATABASE_SA_NAME}", + f"bound_service_account_namespaces={namespace}", + f"policies={vault_database_policy_name}", + ] + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup_om_backup +def test_om_created(ops_manager: MongoDBOpsManager): + ops_manager.backup_status().assert_reaches_phase( + Phase.Pending, + msg_regexp="The MongoDB object .+ doesn't exist", + timeout=900, + ) + + +@mark.e2e_vault_setup_om_backup +def test_prometheus_endpoint_works_on_every_pod_on_appdb(ops_manager: MongoDB): + auth = ("prom-user", "prom-password") + name = ops_manager.name + "-db" + + for idx in range(ops_manager["spec"]["applicationDatabase"]["members"]): + url = f"https://{name}-{idx}.{name}-svc.{ops_manager.namespace}.svc.cluster.local:9216/metrics" + assert https_endpoint_is_reachable(url, auth, tls_verify=False) + + +@mark.e2e_vault_setup_om_backup +def test_backup_mdbs_created( + oplog_replica_set: MongoDB, + s3_replica_set: MongoDB, +): + """Creates mongodb databases all at once""" + oplog_replica_set.assert_reaches_phase(Phase.Running) + s3_replica_set.assert_reaches_phase(Phase.Running) + + +@mark.e2e_vault_setup_om_backup +def test_om_backup_running(ops_manager: MongoDBOpsManager): + ops_manager.backup_status().assert_reaches_phase( + Phase.Running, + ) + + +@mark.e2e_vault_setup_om_backup +def test_no_admin_key_secret_in_kubernetes( + namespace: str, ops_manager: MongoDBOpsManager +): + with pytest.raises(ApiException): + read_secret(namespace, f"{namespace}-{ops_manager.name}-admin-key") + + +@mark.e2e_vault_setup_om_backup +def test_appdb_reached_running_and_pod_count( + ops_manager: MongoDBOpsManager, namespace: str +): + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=400) + # check AppDB has 4 containers(+1 because of vault-agent) + for pod_name in get_pods(ops_manager.name + "-db-{}", 3): + pod = client.CoreV1Api().read_namespaced_pod(pod_name, namespace) + assert len(pod.spec.containers) == 4 + + +@mark.e2e_vault_setup_om_backup +def test_no_s3_credentials__secret_in_kubernetes( + namespace: str, ops_manager: MongoDBOpsManager +): + with pytest.raises(ApiException): + read_secret( + namespace, + S3_SECRET_NAME, + ) diff --git a/docker/mongodb-enterprise-tests/tests/vaultintegration/om_deployment_vault.py b/docker/mongodb-enterprise-tests/tests/vaultintegration/om_deployment_vault.py new file mode 100644 index 000000000..16a04857d --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/vaultintegration/om_deployment_vault.py @@ -0,0 +1,386 @@ +from kubetester.opsmanager import MongoDBOpsManager +from typing import Optional +from pytest import fixture, mark +import pytest +import time +from kubetester.operator import Operator +from . import run_command_in_vault, store_secret_in_vault, assert_secret_in_vault +from kubetester import ( + get_statefulset, + create_secret, + delete_secret, + create_configmap, + read_secret, +) +from kubetester.certs import create_ops_manager_tls_certs, create_mongodb_tls_certs +from kubetester.kubetester import KubernetesTester, fixture as yaml_fixture +from kubetester.mongodb import Phase, get_pods +from kubernetes.client.rest import ApiException +from kubernetes import client + +OPERATOR_NAME = "mongodb-enterprise-operator" +APPDB_SA_NAME = "mongodb-enterprise-appdb" +OM_SA_NAME = "mongodb-enterprise-ops-manager" +OM_NAME = "om-basic" + + +@fixture(scope="module") +def ops_manager_certs(namespace: str, issuer: str): + prefix = "prefix" + return create_ops_manager_tls_certs( + issuer, + namespace, + om_name=OM_NAME, + secret_name=f"{prefix}-{OM_NAME}-cert", + secret_backend="Vault", + ) + + +@fixture(scope="module") +def appdb_certs(namespace: str, issuer: str): + create_mongodb_tls_certs( + issuer, + namespace, + f"{OM_NAME}-db", + f"appdb-{OM_NAME}-db-cert", + secret_backend="Vault", + vault_subpath="appdb", + ) + return "appdb" + + +@fixture(scope="module") +def ops_manager( + namespace: str, + custom_version: Optional[str], + custom_appdb_version: str, + appdb_certs: str, + issuer_ca_configmap: str, + ops_manager_certs: str, +) -> MongoDBOpsManager: + om = MongoDBOpsManager.from_yaml( + yaml_fixture("om_ops_manager_basic.yaml"), namespace=namespace + ) + om["spec"]["security"] = { + "tls": {"ca": issuer_ca_configmap}, + "certsSecretPrefix": "prefix", + } + om["spec"]["applicationDatabase"]["security"] = { + "tls": { + "ca": issuer_ca_configmap, + }, + "certsSecretPrefix": "appdb", + } + om.set_version(custom_version) + om.set_appdb_version(custom_appdb_version) + + return om.create() + + +@mark.e2e_vault_setup_om +def test_vault_creation(vault: str, vault_name: str, vault_namespace: str): + sts = get_statefulset(namespace=vault_namespace, name=vault_name) + assert sts.status.ready_replicas == 1 + + +@mark.e2e_vault_setup_om +def test_create_vault_operator_policy(vault_name: str, vault_namespace: str): + # copy hcl file from local machine to pod + KubernetesTester.copy_file_inside_pod( + f"{vault_name}-0", + "vaultpolicies/operator-policy.hcl", + "/tmp/operator-policy.hcl", + namespace=vault_namespace, + ) + + cmd = [ + "vault", + "policy", + "write", + "mongodbenterprise", + "/tmp/operator-policy.hcl", + ] + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup_om +def test_enable_kubernetes_auth(vault_name: str, vault_namespace: str): + cmd = [ + "vault", + "auth", + "enable", + "kubernetes", + ] + + run_command_in_vault( + vault_namespace, + vault_name, + cmd, + expected_message=["Success!", "path is already in use at kubernetes"], + ) + + cmd = [ + "cat", + "/var/run/secrets/kubernetes.io/serviceaccount/token", + ] + + token = run_command_in_vault(vault_namespace, vault_name, cmd, expected_message=[]) + cmd = ["env"] + + response = run_command_in_vault( + vault_namespace, vault_name, cmd, expected_message=[] + ) + + response = response.split("\n") + for line in response: + l = line.strip() + if str.startswith(l, "KUBERNETES_PORT_443_TCP_ADDR"): + cluster_ip = l.split("=")[1] + break + cmd = [ + "vault", + "write", + "auth/kubernetes/config", + f"token_reviewer_jwt={token}", + f"kubernetes_host=https://{cluster_ip}:443", + "kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt", + "disable_iss_validation=true", + ] + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup_om +def test_enable_vault_role_for_operator_pod( + vault_name: str, + vault_namespace: str, + namespace: str, + vault_operator_policy_name: str, +): + cmd = [ + "vault", + "write", + "auth/kubernetes/role/mongodbenterprise", + f"bound_service_account_names={OPERATOR_NAME}", + f"bound_service_account_namespaces={namespace}", + f"policies={vault_operator_policy_name}", + ] + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup_om +def test_put_admin_credentials_to_vault( + namespace: str, vault_namespace: str, vault_name: str +): + admin_credentials_secret_name = "ops-manager-admin-secret" + # read the -admin-secret from namespace and store in vault + data = read_secret(namespace, admin_credentials_secret_name) + path = ( + f"secret/mongodbenterprise/operator/{namespace}/{admin_credentials_secret_name}" + ) + store_secret_in_vault(vault_namespace, vault_name, data, path) + delete_secret(namespace, admin_credentials_secret_name) + + +@mark.e2e_vault_setup_om +def test_operator_install_with_vault_backend(operator_vault_secret_backend: Operator): + operator_vault_secret_backend.assert_is_running() + + +@mark.e2e_vault_setup_om +def test_create_appdb_policy(vault_name: str, vault_namespace: str): + KubernetesTester.copy_file_inside_pod( + f"{vault_name}-0", + "vaultpolicies/appdb-policy.hcl", + "/tmp/appdb-policy.hcl", + namespace=vault_namespace, + ) + + cmd = [ + "vault", + "policy", + "write", + "mongodbenterpriseappdb", + "/tmp/appdb-policy.hcl", + ] + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup_om +def test_create_om_policy(vault_name: str, vault_namespace: str): + KubernetesTester.copy_file_inside_pod( + f"{vault_name}-0", + "vaultpolicies/opsmanager-policy.hcl", + "/tmp/om-policy.hcl", + namespace=vault_namespace, + ) + + cmd = [ + "vault", + "policy", + "write", + "mongodbenterpriseopsmanager", + "/tmp/om-policy.hcl", + ] + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup_om +def test_enable_vault_role_for_appdb_pod( + vault_name: str, + vault_namespace: str, + namespace: str, + vault_appdb_policy_name: str, +): + cmd = [ + "vault", + "write", + f"auth/kubernetes/role/{vault_appdb_policy_name}", + f"bound_service_account_names={APPDB_SA_NAME}", + f"bound_service_account_namespaces={namespace}", + f"policies={vault_appdb_policy_name}", + ] + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup_om +def test_enable_vault_role_for_om_pod( + vault_name: str, + vault_namespace: str, + namespace: str, + vault_om_policy_name: str, +): + cmd = [ + "vault", + "write", + f"auth/kubernetes/role/{vault_om_policy_name}", + f"bound_service_account_names={OM_SA_NAME}", + f"bound_service_account_namespaces={namespace}", + f"policies={vault_om_policy_name}", + ] + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup_om +def test_om_created(ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=600) + + +@mark.e2e_vault_setup_om +def test_no_admin_key_secret_in_kubernetes( + namespace: str, ops_manager: MongoDBOpsManager +): + with pytest.raises(ApiException): + read_secret(namespace, f"{namespace}-{ops_manager.name}-admin-key") + + +@mark.e2e_vault_setup_om +def test_no_gen_key_secret_in_kubernetes( + namespace: str, ops_manager: MongoDBOpsManager +): + with pytest.raises(ApiException): + read_secret(namespace, f"{ops_manager.name}-gen-key") + + +@mark.e2e_vault_setup_om +def test_appdb_reached_running_and_pod_count( + ops_manager: MongoDBOpsManager, namespace: str +): + ops_manager.appdb_status().assert_reaches_phase(Phase.Running, timeout=400) + # check AppDB has 4 containers(+1 because of vault-agent) + for pod_name in get_pods(ops_manager.name + "-db-{}", 3): + pod = client.CoreV1Api().read_namespaced_pod(pod_name, namespace) + assert len(pod.spec.containers) == 4 + + +@mark.e2e_vault_setup_om +def test_no_appdb_connection_string_secret( + namespace: str, ops_manager: MongoDBOpsManager +): + with pytest.raises(ApiException): + read_secret(namespace, f"{ops_manager.name}-db-connection-string") + + +@mark.e2e_vault_setup_om +def test_no_db_agent_password_secret_in_kubernetes( + namespace: str, ops_manager: MongoDBOpsManager +): + with pytest.raises(ApiException): + read_secret(namespace, f"{ops_manager.name}-db-agent-password") + + +@mark.e2e_vault_setup_om +def test_no_db_scram_password_secret_in_kubernetes( + namespace: str, ops_manager: MongoDBOpsManager +): + with pytest.raises(ApiException): + read_secret(namespace, f"{ops_manager.name}-db-om-user-scram-credentials") + + +@mark.e2e_vault_setup_om +def test_no_om_password_secret_in_kubernetes( + namespace: str, ops_manager: MongoDBOpsManager +): + with pytest.raises(ApiException): + read_secret(namespace, f"{ops_manager.name}-db-om-password") + + +@mark.e2e_vault_setup_om +def test_no_db_keyfile_secret_in_kubernetes( + namespace: str, ops_manager: MongoDBOpsManager +): + with pytest.raises(ApiException): + read_secret(namespace, f"{ops_manager.name}-db-keyfile") + + +@mark.e2e_vault_setup_om +def test_no_db_automation_config_secret_in_kubernetes( + namespace: str, ops_manager: MongoDBOpsManager +): + with pytest.raises(ApiException): + read_secret(namespace, f"{ops_manager.name}-db-config") + + +@mark.e2e_vault_setup_om +def test_no_db_monitoring_automation_config_secret_in_kubernetes( + namespace: str, ops_manager: MongoDBOpsManager +): + with pytest.raises(ApiException): + read_secret(namespace, f"{ops_manager.name}-db-monitoring-config") + + +@mark.e2e_vault_setup_om +def test_rotate_appdb_certs( + ops_manager: MongoDBOpsManager, + vault_namespace: str, + vault_name: str, + namespace: str, +): + omTries = 10 + while omTries > 0: + ops_manager.load() + secret_name = f"appdb-{ops_manager.name}-db-cert" + if secret_name not in ops_manager["metadata"]["annotations"]: + omTries -= 1 + time.sleep(30) + continue + old_version = ops_manager["metadata"]["annotations"][secret_name] + break + + cmd = [ + "vault", + "kv", + "patch", + f"secret/mongodbenterprise/appdb/{namespace}/{secret_name}", + "foo=bar", + ] + + run_command_in_vault(vault_namespace, vault_name, cmd, ["version"]) + + tries = 30 + while tries > 0: + ops_manager.load() + if old_version != ops_manager["metadata"]["annotations"][secret_name]: + return + tries -= 1 + time.sleep(30) + pytest.fail("Not reached new annotation") diff --git a/docker/mongodb-enterprise-tests/tests/vaultintegration/vault_tls.py b/docker/mongodb-enterprise-tests/tests/vaultintegration/vault_tls.py new file mode 100644 index 000000000..9f86e49d8 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/vaultintegration/vault_tls.py @@ -0,0 +1,322 @@ +from pytest import mark, fixture +from kubetester import get_statefulset, read_secret, delete_secret, create_secret +from . import run_command_in_vault, store_secret_in_vault, assert_secret_in_vault +from kubetester.operator import Operator +from kubetester.kubetester import KubernetesTester, fixture as yaml_fixture +from kubetester.mongodb import MongoDB, Phase, get_pods +from kubernetes import client +from kubernetes.client import V1ConfigMap +from kubetester.opsmanager import MongoDBOpsManager +from typing import Optional +from kubetester.certs import Certificate + + +OPERATOR_NAME = "mongodb-enterprise-operator" +DATABASE_SA_NAME = "mongodb-enterprise-database-pods" +MDB_RESOURCE = "my-replica-set" +APPDB_SA_NAME = "mongodb-enterprise-appdb" +OM_SA_NAME = "mongodb-enterprise-ops-manager" +OM_NAME = "om-basic" + + +@fixture(scope="module") +def replica_set( + namespace: str, + custom_mdb_version: str, +) -> MongoDB: + resource = MongoDB.from_yaml(yaml_fixture("replica-set.yaml"), MDB_RESOURCE, namespace) + resource.set_version(custom_mdb_version) + resource.create() + + return resource + + +@fixture(scope="module") +def ops_manager( + namespace: str, + custom_version: Optional[str], + custom_appdb_version: str, +) -> MongoDBOpsManager: + om = MongoDBOpsManager.from_yaml(yaml_fixture("om_ops_manager_basic.yaml"), namespace=namespace) + om["spec"]["backup"] = { + "enabled": False, + } + om.set_version(custom_version) + om.set_appdb_version("5.0.16-ent") + + return om.create() + + +@mark.e2e_vault_setup_tls +def test_vault_creation(vault_tls: str, vault_name: str, vault_namespace: str, issuer: str): + vault_tls + + # assert if vault statefulset is ready, this is sort of redundant(we already assert for pod phase) + # but this is basic assertion at the moment, will remove in followup PR + sts = get_statefulset(namespace=vault_namespace, name=vault_name) + assert sts.status.ready_replicas == 1 + + +@mark.e2e_vault_setup_tls +def test_create_appdb_policy(vault_name: str, vault_namespace: str): + KubernetesTester.copy_file_inside_pod( + f"{vault_name}-0", + "vaultpolicies/appdb-policy.hcl", + "/tmp/appdb-policy.hcl", + namespace=vault_namespace, + ) + + cmd = [ + "vault", + "policy", + "write", + "mongodbenterpriseappdb", + "/tmp/appdb-policy.hcl", + ] + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup_tls +def test_enable_kubernetes_auth(vault_name: str, vault_namespace: str): + # enable Kubernetes auth for Vault + cmd = [ + "vault", + "auth", + "enable", + "kubernetes", + ] + + run_command_in_vault( + vault_namespace, + vault_name, + cmd, + expected_message=["Success!", "path is already in use at kubernetes"], + ) + + cmd = [ + "cat", + "/var/run/secrets/kubernetes.io/serviceaccount/token", + ] + + token = run_command_in_vault(vault_namespace, vault_name, cmd, expected_message=[]) + + cmd = ["env"] + + response = run_command_in_vault(vault_namespace, vault_name, cmd, expected_message=[]) + + response = response.split("\n") + for line in response: + l = line.strip() + if str.startswith(l, "KUBERNETES_PORT_443_TCP_ADDR"): + cluster_ip = l.split("=")[1] + break + + cmd = [ + "vault", + "write", + "auth/kubernetes/config", + f"token_reviewer_jwt={token}", + f"kubernetes_host=https://{cluster_ip}:443", + "kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt", + "disable_iss_validation=true", + ] + + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup_tls +def test_create_om_policy(vault_name: str, vault_namespace: str): + KubernetesTester.copy_file_inside_pod( + f"{vault_name}-0", + "vaultpolicies/opsmanager-policy.hcl", + "/tmp/om-policy.hcl", + namespace=vault_namespace, + ) + + cmd = [ + "vault", + "policy", + "write", + "mongodbenterpriseopsmanager", + "/tmp/om-policy.hcl", + ] + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup_tls +def test_enable_vault_role_for_appdb_pod( + vault_name: str, + vault_namespace: str, + namespace: str, + vault_appdb_policy_name: str, +): + cmd = [ + "vault", + "write", + f"auth/kubernetes/role/{vault_appdb_policy_name}", + f"bound_service_account_names={APPDB_SA_NAME}", + f"bound_service_account_namespaces={namespace}", + f"policies={vault_appdb_policy_name}", + ] + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup_tls +def test_enable_vault_role_for_om_pod( + vault_name: str, + vault_namespace: str, + namespace: str, + vault_om_policy_name: str, +): + cmd = [ + "vault", + "write", + f"auth/kubernetes/role/{vault_om_policy_name}", + f"bound_service_account_names={OM_SA_NAME}", + f"bound_service_account_namespaces={namespace}", + f"policies={vault_om_policy_name}", + ] + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup_tls +def test_create_vault_operator_policy(vault_name: str, vault_namespace: str): + # copy hcl file from local machine to pod + KubernetesTester.copy_file_inside_pod( + f"{vault_name}-0", + "vaultpolicies/operator-policy.hcl", + "/tmp/operator-policy.hcl", + namespace=vault_namespace, + ) + + cmd = [ + "vault", + "policy", + "write", + "mongodbenterprise", + "/tmp/operator-policy.hcl", + ] + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup_tls +def test_enable_vault_role_for_operator_pod( + vault_name: str, + vault_namespace: str, + namespace: str, + vault_operator_policy_name: str, +): + cmd = [ + "vault", + "write", + "auth/kubernetes/role/mongodbenterprise", + f"bound_service_account_names={OPERATOR_NAME}", + f"bound_service_account_namespaces={namespace}", + f"policies={vault_operator_policy_name}", + ] + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup_tls +def test_put_admin_credentials_to_vault(namespace: str, vault_namespace: str, vault_name: str): + admin_credentials_secret_name = "ops-manager-admin-secret" + # read the -admin-secret from namespace and store in vault + data = read_secret(namespace, admin_credentials_secret_name) + path = f"secret/mongodbenterprise/operator/{namespace}/{admin_credentials_secret_name}" + store_secret_in_vault(vault_namespace, vault_name, data, path) + delete_secret(namespace, admin_credentials_secret_name) + + +@mark.e2e_vault_setup_tls +def test_remove_cert_and_key_from_secret(namespace: str): + data = read_secret(namespace, "vault-tls") + cert = Certificate(name="vault-tls", namespace=namespace).load() + cert.delete() + del data["tls.crt"] + del data["tls.key"] + delete_secret(namespace, "vault-tls") + create_secret(namespace, "vault-tls", data) + + +@mark.e2e_vault_setup_tls +def test_operator_install_with_vault_backend( + operator_vault_secret_backend_tls: Operator, +): + operator_vault_secret_backend_tls.assert_is_running() + + +@mark.e2e_vault_setup_tls +def test_store_om_credentials_in_vault(vault_namespace: str, vault_name: str, namespace: str): + credentials = read_secret(namespace, "my-credentials") + store_secret_in_vault( + vault_namespace, + vault_name, + credentials, + f"secret/mongodbenterprise/operator/{namespace}/my-credentials", + ) + + cmd = [ + "vault", + "kv", + "get", + f"secret/mongodbenterprise/operator/{namespace}/my-credentials", + ] + run_command_in_vault(vault_namespace, vault_name, cmd, expected_message=["publicApiKey"]) + delete_secret(namespace, "my-credentials") + + +@mark.e2e_vault_setup_tls +def test_create_database_policy(vault_name: str, vault_namespace: str): + KubernetesTester.copy_file_inside_pod( + f"{vault_name}-0", + "vaultpolicies/database-policy.hcl", + "/tmp/database-policy.hcl", + namespace=vault_namespace, + ) + + cmd = [ + "vault", + "policy", + "write", + "mongodbenterprisedatabase", + "/tmp/database-policy.hcl", + ] + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup_tls +def test_enable_vault_role_for_database_pod( + vault_name: str, + vault_namespace: str, + namespace: str, + vault_database_policy_name: str, +): + cmd = [ + "vault", + "write", + f"auth/kubernetes/role/{vault_database_policy_name}", + f"bound_service_account_names={DATABASE_SA_NAME}", + f"bound_service_account_namespaces={namespace}", + f"policies={vault_database_policy_name}", + ] + run_command_in_vault(vault_namespace, vault_name, cmd) + + +@mark.e2e_vault_setup_tls +def test_om_created(ops_manager: MongoDBOpsManager): + ops_manager.om_status().assert_reaches_phase(Phase.Running, timeout=600) + + +@mark.e2e_vault_setup_tls +def test_mdb_created(replica_set: MongoDB, namespace: str): + replica_set.assert_reaches_phase(Phase.Running, timeout=500, ignore_errors=True) + for pod_name in get_pods(MDB_RESOURCE + "-{}", 3): + pod = client.CoreV1Api().read_namespaced_pod(pod_name, namespace) + assert len(pod.spec.containers) == 2 + + +@mark.e2e_vault_setup_tls +def test_no_cert_in_secret(namespace: str): + data = read_secret(namespace, "vault-tls") + assert "tls.crt" not in data + assert "tls.key" not in data diff --git a/docker/mongodb-enterprise-tests/tests/webhooks/__init__.py b/docker/mongodb-enterprise-tests/tests/webhooks/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/docker/mongodb-enterprise-tests/tests/webhooks/conftest.py b/docker/mongodb-enterprise-tests/tests/webhooks/conftest.py new file mode 100644 index 000000000..cb0664057 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/webhooks/conftest.py @@ -0,0 +1,4 @@ +def pytest_runtest_setup(item): + """This allows to automatically install the default Operator before running any test""" + if "default_operator" not in item.fixturenames: + item.fixturenames.insert(0, "default_operator") diff --git a/docker/mongodb-enterprise-tests/tests/webhooks/e2e_mongodb_roles_validation_webhook.py b/docker/mongodb-enterprise-tests/tests/webhooks/e2e_mongodb_roles_validation_webhook.py new file mode 100644 index 000000000..fdb5e3b2e --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/webhooks/e2e_mongodb_roles_validation_webhook.py @@ -0,0 +1,242 @@ +import pytest +from pytest import fixture +from kubernetes import client +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.mongodb import MongoDB +from kubernetes.client.rest import ApiException + + +@fixture(scope="function") +def mdb(namespace: str) -> str: + return MongoDB.from_yaml( + yaml_fixture("role-validation-base.yaml"), namespace=namespace + ) + + +# Basic testing for invalid empty values +@pytest.mark.e2e_mongodb_roles_validation_webhook +def test_empty_role_name(mdb: str): + mdb["spec"]["security"]["roles"] = [ + { + "role": "", + "db": "admin", + "privileges": [ + { + "actions": ["insert"], + "resource": {"collection": "foo", "db": "admin"}, + } + ], + } + ] + with pytest.raises( + client.rest.ApiException, + match="Error validating role - Cannot create a role with an empty name", + ): + mdb.create() + + +@pytest.mark.e2e_mongodb_roles_validation_webhook +def test_empty_db_name(mdb: str): + mdb["spec"]["security"]["roles"] = [ + { + "role": "role", + "db": "", + "privileges": [ + { + "actions": ["insert"], + "resource": {"collection": "foo", "db": "admin"}, + } + ], + } + ] + with pytest.raises( + client.rest.ApiException, + match="Error validating role - Cannot create a role with an empty db", + ): + mdb.create() + + +@pytest.mark.e2e_mongodb_roles_validation_webhook +def test_inherited_role_empty_name(mdb: str): + mdb["spec"]["security"]["roles"] = [ + { + "role": "role", + "db": "admin", + "privileges": [ + { + "actions": ["insert"], + "resource": {"collection": "foo", "db": "admin"}, + } + ], + "roles": [{"db": "admin", "role": ""}], + } + ] + with pytest.raises( + client.rest.ApiException, + match="Error validating role - Cannot inherit from a role with an empty name", + ): + mdb.create() + + +@pytest.mark.e2e_mongodb_roles_validation_webhook +def test_inherited_role_empty_db(mdb: str): + mdb["spec"]["security"]["roles"] = [ + { + "role": "role", + "db": "admin", + "privileges": [ + { + "actions": ["insert"], + "resource": {"collection": "foo", "db": "admin"}, + } + ], + "roles": [{"db": "", "role": "role"}], + } + ] + with pytest.raises( + client.rest.ApiException, + match="Error validating role - Cannot inherit from a role with an empty db", + ): + mdb.create() + + +# Testing for invalid authentication Restrictions +@pytest.mark.e2e_mongodb_roles_validation_webhook +def test_invalid_client_source(mdb: str): + mdb["spec"]["security"]["roles"] = [ + { + "role": "role", + "db": "admin", + "privileges": [ + { + "actions": ["insert"], + "resource": {"collection": "foo", "db": "admin"}, + } + ], + "authenticationRestrictions": [{"clientSource": ["355.127.0.1"]}], + } + ] + with pytest.raises( + client.rest.ApiException, + match="Error validating role - AuthenticationRestriction is invalid - clientSource 355.127.0.1 is neither a valid IP address nor a valid CIDR range", + ): + mdb.create() + + +@pytest.mark.e2e_mongodb_roles_validation_webhook +def test_invalid_server_address(mdb: str): + mdb["spec"]["security"]["roles"] = [ + { + "role": "role", + "db": "admin", + "privileges": [ + { + "actions": ["insert"], + "resource": {"collection": "foo", "db": "admin"}, + } + ], + "authenticationRestrictions": [{"serverAddress": ["355.127.0.1"]}], + } + ] + with pytest.raises( + client.rest.ApiException, + match="Error validating role - AuthenticationRestriction is invalid - serverAddress 355.127.0.1 is neither a valid IP address nor a valid CIDR range", + ): + mdb.create() + + +# Testing for invalid privileges +@pytest.mark.e2e_mongodb_roles_validation_webhook +def test_invalid_cluster_and_db_collection(mdb: str): + mdb["spec"]["security"]["roles"] = [ + { + "role": "role", + "db": "admin", + "privileges": [ + { + "actions": ["insert"], + "resource": {"collection": "foo", "db": "admin", "cluster": True}, + } + ], + } + ] + with pytest.raises( + client.rest.ApiException, + match="Error validating role - Privilege is invalid - Cluster: true is not compatible with setting db/collection", + ): + mdb.create() + + +@pytest.mark.e2e_mongodb_roles_validation_webhook +def test_invalid_cluster_not_true(mdb: str): + mdb["spec"]["security"]["roles"] = [ + { + "role": "role", + "db": "admin", + "privileges": [{"actions": ["insert"], "resource": {"cluster": False}}], + } + ] + with pytest.raises( + client.rest.ApiException, + match="Error validating role - Privilege is invalid - The only valid value for privilege.cluster, if set, is true", + ): + mdb.create() + + +@pytest.mark.e2e_mongodb_roles_validation_webhook +def test_invalid_action(mdb: str): + mdb["spec"]["security"]["roles"] = [ + { + "role": "role", + "db": "admin", + "privileges": [ + { + "actions": ["insertFoo"], + "resource": {"collection": "foo", "db": "admin"}, + } + ], + } + ] + with pytest.raises( + client.rest.ApiException, + match="Error validating role - Privilege is invalid - Actions are not valid - insertFoo is not a valid db action", + ): + mdb.create() + + +# Testing for privileges invalid for mongodb version +@pytest.mark.e2e_mongodb_roles_validation_webhook +def test_invalid_privilege_for_mongodb_less_than_four_two(mdb: str): + mdb["spec"]["security"]["roles"] = [ + { + "role": "role", + "db": "admin", + "privileges": [ + {"actions": ["dropConnections"], "resource": {"cluster": True}} + ], + } + ] + with pytest.raises( + client.rest.ApiException, + match="Error validating role - Privilege is invalid - Actions are not valid - Some of the provided actions are not valid for MongoDB 4.0.12", + ): + mdb.create() + + +@pytest.mark.e2e_mongodb_roles_validation_webhook +def test_invalid_privilege_for_mongodb_less_than_three_six(mdb: str): + mdb["spec"]["security"]["roles"] = [ + { + "role": "role", + "db": "admin", + "privileges": [ + {"actions": ["listSessions"], "resource": {"cluster": True}} + ], + } + ] + mdb["spec"]["version"] = "3.5.0" + with pytest.raises( + client.rest.ApiException, + match="Error validating role - Privilege is invalid - Actions are not valid - Some of the provided actions are not valid for MongoDB 3.5.0", + ): + mdb.create() diff --git a/docker/mongodb-enterprise-tests/tests/webhooks/e2e_mongodb_validation_webhook.py b/docker/mongodb-enterprise-tests/tests/webhooks/e2e_mongodb_validation_webhook.py new file mode 100644 index 000000000..f5ca806cf --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/webhooks/e2e_mongodb_validation_webhook.py @@ -0,0 +1,142 @@ +import yaml +import pytest +from kubetester.kubetester import fixture as yaml_fixture, KubernetesTester + + +@pytest.mark.e2e_mongodb_validation_webhook +class TestWebhookValidation(KubernetesTester): + def test_horizons_tls_validation(self): + resource = yaml.safe_load( + open(yaml_fixture("invalid_replica_set_horizons_tls.yaml")) + ) + self.create_custom_resource_from_object( + self.get_namespace(), + resource, + exception_reason="TLS must be enabled in order to use replica set horizons", + ) + + def test_horizons_members(self): + resource = yaml.safe_load( + open(yaml_fixture("invalid_replica_set_horizons_members.yaml")) + ) + self.create_custom_resource_from_object( + self.get_namespace(), + resource, + exception_reason="Number of horizons must be equal to number of members in replica set", + ) + + def test_x509_without_tls(self): + resource = yaml.safe_load( + open(yaml_fixture("invalid_replica_set_x509_no_tls.yaml")) + ) + self.create_custom_resource_from_object( + self.get_namespace(), + resource, + exception_reason="Cannot have a non-tls deployment when x509 authentication is enabled", + ) + + def test_auth_without_modes(self): + resource = yaml.safe_load( + open(yaml_fixture("invalid_replica_set_agent_auth_not_in_modes.yaml")) + ) + self.create_custom_resource_from_object( + self.get_namespace(), + resource, + exception_reason="Cannot configure an Agent authentication mechanism that is not specified in authentication modes", + ) + + def test_agent_auth_enabled_with_no_modes(self): + resource = yaml.safe_load( + open(yaml_fixture("invalid_replica_set_auth_no_modes.yaml")) + ) + self.create_custom_resource_from_object( + self.get_namespace(), + resource, + exception_reason="Cannot enable authentication without modes specified", + ) + + def test_ldap_auth_with_mongodb_community(self): + resource = yaml.safe_load( + open(yaml_fixture("invalid_replica_set_ldap_community.yaml")) + ) + self.create_custom_resource_from_object( + self.get_namespace(), + resource, + exception_reason="Cannot enable LDAP authentication with MongoDB Community Builds", + ) + + def test_no_agent_auth_mode_with_multiple_modes_enabled(self): + resource = yaml.safe_load( + open(yaml_fixture("invalid_replica_set_no_agent_mode.yaml")) + ) + self.create_custom_resource_from_object( + self.get_namespace(), + resource, + exception_reason="spec.security.authentication.agents.mode must be specified if more than one entry is present in spec.security.authentication.modes", + ) + + def test_ldap_auth_with_no_ldapgroupdn(self): + resource = yaml.safe_load( + open(yaml_fixture("invalid_replica_set_ldapauthz_no_ldapgroupdn.yaml")) + ) + self.create_custom_resource_from_object( + self.get_namespace(), + resource, + exception_reason="automationLdapGroupDN must be specified if LDAP authorization is used and agent auth mode is $external (x509 or LDAP)", + ) + + def test_replicaset_members_is_specified(self): + resource = yaml.safe_load(open(yaml_fixture("invalid_mdb_member_count.yaml"))) + + self.create_custom_resource_from_object( + self.get_namespace(), + resource, + exception_reason="'spec.members' must be specified if type of MongoDB is ReplicaSet", + ) + + def test_replicaset_members_is_specified_without_webhook(self): + self._assert_validates_without_webhook( + "mdbpolicy.mongodb.com", + "invalid_mdb_member_count.yaml", + "'spec.members' must be specified if type of MongoDB is ReplicaSet", + ) + + def test_horizons_without_tls_validates_without_webhook(self): + self._assert_validates_without_webhook( + "mdbpolicy.mongodb.com", + "invalid_replica_set_horizons_tls.yaml", + "TLS must be enabled", + ) + + def test_incorrect_members_validates_without_webhook(self): + self._assert_validates_without_webhook( + "mdbpolicy.mongodb.com", + "invalid_replica_set_horizons_members.yaml", + "number of members", + ) + + def _assert_validates_without_webhook( + self, webhook_name: str, fixture: str, expected_msg: str + ): + webhook_api = self.client.AdmissionregistrationV1Api() + + # break the existing webhook + webhook = webhook_api.read_validating_webhook_configuration(webhook_name) + old_webhooks = webhook.webhooks + webhook.webhooks[0].client_config.service.name = "a-non-existent-service" + webhook.metadata.uid = "" + webhook_api.replace_validating_webhook_configuration(webhook_name, webhook) + + # check that the webhook doesn't block and that the resource gets into + # the errored state + resource = yaml.safe_load(open(yaml_fixture(fixture))) + self.create_custom_resource_from_object(self.get_namespace(), resource) + KubernetesTester.wait_until("in_error_state", 20) + mrs = KubernetesTester.get_resource() + assert expected_msg in mrs["status"]["message"] + + # fix webhooks + webhook = webhook_api.read_validating_webhook_configuration(webhook_name) + webhook.webhooks = old_webhooks + webhook.metadata.uid = "" + webhook_api.replace_validating_webhook_configuration(webhook_name, webhook) diff --git a/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_appdb_shard_count.yaml b/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_appdb_shard_count.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_mdb_member_count.yaml b/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_mdb_member_count.yaml new file mode 100644 index 000000000..de6eee8f3 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_mdb_member_count.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: test-rs-member-not-specified +spec: + version: 4.0.12 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + + security: + tls: + enabled: true + persistent: false \ No newline at end of file diff --git a/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_agent_auth_not_in_modes.yaml b/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_agent_auth_not_in_modes.yaml new file mode 100644 index 000000000..404bb3e94 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_agent_auth_not_in_modes.yaml @@ -0,0 +1,23 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: test-rs-invalid-agent-auth-not-in-modes +spec: + members: 3 + version: 4.0.12 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + + persistent: false + + security: + authentication: + agents: + mode: X509 + enabled: true + modes: ["SCRAM"] diff --git a/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_auth_no_modes.yaml b/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_auth_no_modes.yaml new file mode 100644 index 000000000..66318698e --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_auth_no_modes.yaml @@ -0,0 +1,23 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: test-rs-invalid-auth +spec: + members: 3 + version: 4.0.12 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + + persistent: false + + security: + authentication: + agents: + mode: SCRAM + enabled: true + modes: [] diff --git a/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_horizons_members.yaml b/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_horizons_members.yaml new file mode 100644 index 000000000..bf61090a9 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_horizons_members.yaml @@ -0,0 +1,28 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: test-rs-external-access-multiple-horizons-member-count +spec: + members: 3 + version: 4.0.12 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + + security: + tls: + enabled: true + + persistent: false + + connectivity: + replicaSetHorizons: + - "test-horizon-1": "mdb0-test-1-website.com:1337" + - "test-horizon-1": "mdb1-test-1-website.com:1338" + - "test-horizon-1": "mdb2-test-1-website.com:1339" + - "test-horizon-2": "mdb0-test-2-website.com:2337" + - "test-horizon-2": "mdb2-test-2-website.com:2339" diff --git a/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_horizons_tls.yaml b/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_horizons_tls.yaml new file mode 100644 index 000000000..1b26690cc --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_horizons_tls.yaml @@ -0,0 +1,25 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: test-tls-rs-external-access-multiple-horizons +spec: + members: 3 + version: 4.0.12 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + + persistent: false + + connectivity: + replicaSetHorizons: + - "test-horizon-1": "mdb0-test-1-website.com:1337" + "test-horizon-2": "mdb0-test-2-website.com:2337" + - "test-horizon-1": "mdb1-test-1-website.com:1338" + "test-horizon-2": "mdb1-test-2-website.com:2338" + - "test-horizon-1": "mdb2-test-1-website.com:1339" + "test-horizon-2": "mdb2-test-2-website.com:2339" diff --git a/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_ldap_community.yaml b/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_ldap_community.yaml new file mode 100644 index 000000000..f4250184d --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_ldap_community.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: test-tls-rs-external-access-multiple-horizons +spec: + members: 3 + version: 4.0.12 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + security: + authentication: + enabled: true + modes: ["LDAP"] diff --git a/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_ldapauthz_no_ldapgroupdn.yaml b/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_ldapauthz_no_ldapgroupdn.yaml new file mode 100644 index 000000000..d5bd5b9b0 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_ldapauthz_no_ldapgroupdn.yaml @@ -0,0 +1,25 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: test-invalid-replica-set-ldapauthz-no-ldapgroupdn +spec: + members: 3 + version: 4.0.12-ent + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + + persistent: false + + security: + authentication: + agents: + mode: "LDAP" + enabled: true + modes: ["SCRAM", "LDAP"] + ldap: + authzQueryTemplate: "{USER}?memberOf?base" diff --git a/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_no_agent_mode.yaml b/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_no_agent_mode.yaml new file mode 100644 index 000000000..652206623 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_no_agent_mode.yaml @@ -0,0 +1,21 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: test-invalid-replica-set-no-agent-auth-mode +spec: + members: 3 + version: 4.0.12-ent + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + + persistent: false + + security: + authentication: + enabled: true + modes: ["SCRAM", "LDAP"] diff --git a/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_x509_no_tls.yaml b/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_x509_no_tls.yaml new file mode 100644 index 000000000..167a15373 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/invalid_replica_set_x509_no_tls.yaml @@ -0,0 +1,22 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: test-tls-rs-external-access-multiple-horizons +spec: + members: 3 + version: 4.0.12 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + logLevel: DEBUG + + persistent: false + + security: + authentication: + enabled: true + modes: ["X509"] + internalCluster: "X509" diff --git a/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/role-validation-base.yaml b/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/role-validation-base.yaml new file mode 100644 index 000000000..2af894881 --- /dev/null +++ b/docker/mongodb-enterprise-tests/tests/webhooks/fixtures/role-validation-base.yaml @@ -0,0 +1,30 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: test-tls-rs-external-access-invalid-action +spec: + members: 3 + version: 4.0.12 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + security: + role: "" + db: "" + privileges: + actions: + resource: + db: + collection: + cluster: + authenticationRestrictions: + clientSource: + serverAddress: + roles: + name: + db: + diff --git a/docker/mongodb-enterprise-tests/vaultconfig/override.yaml b/docker/mongodb-enterprise-tests/vaultconfig/override.yaml new file mode 100644 index 000000000..5d85c97b3 --- /dev/null +++ b/docker/mongodb-enterprise-tests/vaultconfig/override.yaml @@ -0,0 +1,29 @@ +# The content of this file has been taken from the Vault tutorial +# at https://vaultproject.io/docs/platform/k8s/helm/examples/standalone-tls + +global: + enabled: true + tlsDisable: false + +server: + extraEnvironmentVars: + VAULT_CACERT: /vault/userconfig/vault-tls/ca.crt + + extraVolumes: + - type: secret + name: vault-tls + + standalone: + enabled: true + config: | + listener "tcp" { + address = "0.0.0.0:8200" + cluster_address = "0.0.0.0:8201" + tls_cert_file = "/vault/userconfig/vault-tls/tls.crt" + tls_key_file = "/vault/userconfig/vault-tls/tls.key" + tls_client_ca_file = "/vault/userconfig/vault-tls/ca.crt" + } + + storage "file" { + path = "/vault/data" + } diff --git a/docker/mongodb-enterprise-tests/vaultpolicies/appdb-policy.hcl b/docker/mongodb-enterprise-tests/vaultpolicies/appdb-policy.hcl new file mode 100644 index 000000000..2c13a20d5 --- /dev/null +++ b/docker/mongodb-enterprise-tests/vaultpolicies/appdb-policy.hcl @@ -0,0 +1,8 @@ +// NOTE: if you edit this file, make sure to also edit the one under public/vault_policies + +path "secret/data/mongodbenterprise/appdb/*" { + capabilities = ["read", "list"] +} +path "secret/metadata/mongodbenterprise/appdb/*" { + capabilities = ["list"] +} diff --git a/docker/mongodb-enterprise-tests/vaultpolicies/database-policy.hcl b/docker/mongodb-enterprise-tests/vaultpolicies/database-policy.hcl new file mode 100644 index 000000000..1c0d20486 --- /dev/null +++ b/docker/mongodb-enterprise-tests/vaultpolicies/database-policy.hcl @@ -0,0 +1,8 @@ +// NOTE: if you edit this file, make sure to also edit the one under public/vault_policies + +path "secret/data/mongodbenterprise/database/*" { + capabilities = ["read", "list"] +} +path "secret/metadata/mongodbenterprise/database/*" { + capabilities = ["list"] +} diff --git a/docker/mongodb-enterprise-tests/vaultpolicies/operator-policy.hcl b/docker/mongodb-enterprise-tests/vaultpolicies/operator-policy.hcl new file mode 100644 index 000000000..f23e0b5c1 --- /dev/null +++ b/docker/mongodb-enterprise-tests/vaultpolicies/operator-policy.hcl @@ -0,0 +1,8 @@ +// NOTE: if you edit this file, make sure to also edit the one under public/vault_policies + +path "secret/data/mongodbenterprise/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} +path "secret/metadata/mongodbenterprise/*" { + capabilities = ["list", "read"] +} diff --git a/docker/mongodb-enterprise-tests/vaultpolicies/opsmanager-policy.hcl b/docker/mongodb-enterprise-tests/vaultpolicies/opsmanager-policy.hcl new file mode 100644 index 000000000..fb1bae1b5 --- /dev/null +++ b/docker/mongodb-enterprise-tests/vaultpolicies/opsmanager-policy.hcl @@ -0,0 +1,8 @@ +// NOTE: if you edit this file, make sure to also edit the one under public/vault_policies + +path "secret/data/mongodbenterprise/opsmanager/*" { + capabilities = ["read", "list"] +} +path "secret/metadata/mongodbenterprise/opsmanager/*" { + capabilities = ["list"] +} diff --git a/docker/mongodb-enterprise-tests/vendor/README.md b/docker/mongodb-enterprise-tests/vendor/README.md new file mode 100644 index 000000000..a904a9bd5 --- /dev/null +++ b/docker/mongodb-enterprise-tests/vendor/README.md @@ -0,0 +1,10 @@ +# Vendored Packages + +In this directory you'll find vendored dependencies of the E2E tests. + +## OpenLDAP + +Originally this [Helm chart](https://github.com/helm/charts/tree/master/stable/openldap). + +- This specifically vendors the 1.2.4 version at [this + commit](https://github.com/helm/charts/tree/b2f720d33515d2308c558d927722e197163efc3e/stable/openldap). diff --git a/docker/mongodb-enterprise-tests/vendor/openldap/.helmignore b/docker/mongodb-enterprise-tests/vendor/openldap/.helmignore new file mode 100644 index 000000000..f0c131944 --- /dev/null +++ b/docker/mongodb-enterprise-tests/vendor/openldap/.helmignore @@ -0,0 +1,21 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*~ +# Various IDEs +.project +.idea/ +*.tmproj diff --git a/docker/mongodb-enterprise-tests/vendor/openldap/Chart.yaml b/docker/mongodb-enterprise-tests/vendor/openldap/Chart.yaml new file mode 100644 index 000000000..d8eee030d --- /dev/null +++ b/docker/mongodb-enterprise-tests/vendor/openldap/Chart.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +name: openldap +home: https://www.openldap.org +version: 1.2.4 +appVersion: 2.4.48 +description: Community developed LDAP software +icon: http://www.openldap.org/images/headers/LDAPworm.gif +keywords: + - ldap + - openldap +sources: + - https://github.com/kubernetes/charts +maintainers: + - name: enis + email: enis@apache.org +engine: gotpl diff --git a/docker/mongodb-enterprise-tests/vendor/openldap/README.md b/docker/mongodb-enterprise-tests/vendor/openldap/README.md new file mode 100644 index 000000000..3496c40eb --- /dev/null +++ b/docker/mongodb-enterprise-tests/vendor/openldap/README.md @@ -0,0 +1,100 @@ +# OpenLDAP Helm Chart + +## Prerequisites Details +* Kubernetes 1.8+ +* PV support on the underlying infrastructure + +## Chart Details +This chart will do the following: + +* Instantiate an instance of OpenLDAP server + +## Installing the Chart + +To install the chart with the release name `my-release`: + +```bash +$ helm install --name my-release stable/openldap +``` + +## Configuration + +We use the docker images provided by https://github.com/osixia/docker-openldap. The docker image is highly configurable and well documented. Please consult to documentation for the docker image for more information. + +The following table lists the configurable parameters of the openldap chart and their default values. + +| Parameter | Description | Default | +| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | +| `replicaCount` | Number of replicas | `1` | +| `strategy` | Deployment strategy | `{}` | +| `image.repository` | Container image repository | `osixia/openldap` | +| `image.tag` | Container image tag | `1.1.10` | +| `image.pullPolicy` | Container pull policy | `IfNotPresent` | +| `extraLabels` | Labels to add to the Resources | `{}` | +| `podAnnotations` | Annotations to add to the pod | `{}` | +| `existingSecret` | Use an existing secret for admin and config user passwords | `""` | +| `service.annotations` | Annotations to add to the service | `{}` | +| `service.clusterIP` | IP address to assign to the service | `nil` | +| `service.externalIPs` | Service external IP addresses | `[]` | +| `service.ldapPort` | External service port for LDAP | `389` | +| `service.loadBalancerIP` | IP address to assign to load balancer (if supported) | `""` | +| `service.loadBalancerSourceRanges` | List of IP CIDRs allowed access to load balancer (if supported) | `[]` | +| `service.sslLdapPort` | External service port for SSL+LDAP | `636` | +| `service.type` | Service type | `ClusterIP` | +| `env` | List of key value pairs as env variables to be sent to the docker image. See https://github.com/osixia/docker-openldap for available ones | `[see values.yaml]` | +| `tls.enabled` | Set to enable TLS/LDAPS - should also set `tls.secret` | `false` | +| `tls.secret` | Secret containing TLS cert and key (eg, generated via cert-manager) | `""` | +| `tls.CA.enabled` | Set to enable custom CA crt file - should also set `tls.CA.secret` | `false` | +| `tls.CA.secret` | Secret containing CA certificate (ca.crt) | `""` | +| `adminPassword` | Password for admin user. Unset to auto-generate the password | None | +| `configPassword` | Password for config user. Unset to auto-generate the password | None | +| `customLdifFiles` | Custom ldif files to seed the LDAP server. List of filename -> data pairs | None | +| `persistence.enabled` | Whether to use PersistentVolumes or not | `false` | +| `persistence.storageClass` | Storage class for PersistentVolumes. | `` | +| `persistence.accessMode` | Access mode for PersistentVolumes | `ReadWriteOnce` | +| `persistence.size` | PersistentVolumeClaim storage size | `8Gi` | +| `persistence.existingClaim` | An Existing PVC name for openLDAPA volume | None | +| `resources` | Container resource requests and limits in yaml | `{}` | +| `initResources` | initContainer resource requests and limits in yaml | `{}` | +| `test.enabled` | Conditionally provision test resources | `false` | +| `test.image.repository` | Test container image requires bats framework | `dduportal/bats` | +| `test.image.tag` | Test container tag | `0.4.0` | + + +Specify each parameter using the `--set key=value[,key=value]` argument to `helm install`. + +Alternatively, a YAML file that specifies the values for the parameters can be provided while installing the chart. For example, + +```bash +$ helm install --name my-release -f values.yaml stable/openldap +``` + +> **Tip**: You can use the default [values.yaml](values.yaml) + + +## Cleanup orphaned Persistent Volumes + +Deleting the Deployment will not delete associated Persistent Volumes if persistence is enabled. + +Do the following after deleting the chart release to clean up orphaned Persistent Volumes. + +```bash +$ kubectl delete pvc -l release=${RELEASE-NAME} +``` + +## Custom Secret + +`existingSecret` can be used to override the default secret.yaml provided + +## Testing + +Helm tests are included and they confirm connection to slapd. + +```bash +helm install . --set test.enabled=true +helm test +RUNNING: foolish-mouse-openldap-service-test-akmms +PASSED: foolish-mouse-openldap-service-test-akmms +``` + +It will confirm that we can do an ldapsearch with the default credentials diff --git a/docker/mongodb-enterprise-tests/vendor/openldap/templates/NOTES.txt b/docker/mongodb-enterprise-tests/vendor/openldap/templates/NOTES.txt new file mode 100644 index 000000000..09cf0edc8 --- /dev/null +++ b/docker/mongodb-enterprise-tests/vendor/openldap/templates/NOTES.txt @@ -0,0 +1,20 @@ +OpenLDAP has been installed. You can access the server from within the k8s cluster using: + + {{ template "openldap.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local:{{ .Values.service.ldapPort }} + + +You can access the LDAP adminPassword and configPassword using: + + kubectl get secret --namespace {{ .Release.Namespace }} {{ template "openldap.secretName" . }} -o jsonpath="{.data.LDAP_ADMIN_PASSWORD}" | base64 --decode; echo + kubectl get secret --namespace {{ .Release.Namespace }} {{ template "openldap.secretName" . }} -o jsonpath="{.data.LDAP_CONFIG_PASSWORD}" | base64 --decode; echo + + +You can access the LDAP service, from within the cluster (or with kubectl port-forward) with a command like (replace password and domain): + ldapsearch -x -H ldap://{{ template "openldap.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local:{{ .Values.service.ldapPort }} -b dc=example,dc=org -D "cn=admin,dc=example,dc=org" -w $LDAP_ADMIN_PASSWORD + + +Test server health using Helm test: + helm test {{ .Release.Name }} + + +You can also consider installing the helm chart for phpldapadmin to manage this instance of OpenLDAP, or install Apache Directory Studio, and connect using kubectl port-forward. diff --git a/docker/mongodb-enterprise-tests/vendor/openldap/templates/_helpers.tpl b/docker/mongodb-enterprise-tests/vendor/openldap/templates/_helpers.tpl new file mode 100644 index 000000000..75a118d0f --- /dev/null +++ b/docker/mongodb-enterprise-tests/vendor/openldap/templates/_helpers.tpl @@ -0,0 +1,40 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "openldap.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "openldap.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "openldap.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + + +{{/* +Generate chart secret name +*/}} +{{- define "openldap.secretName" -}} +{{ default (include "openldap.fullname" .) .Values.existingSecret }} +{{- end -}} diff --git a/docker/mongodb-enterprise-tests/vendor/openldap/templates/configmap-customldif.yaml b/docker/mongodb-enterprise-tests/vendor/openldap/templates/configmap-customldif.yaml new file mode 100644 index 000000000..f060d1d8d --- /dev/null +++ b/docker/mongodb-enterprise-tests/vendor/openldap/templates/configmap-customldif.yaml @@ -0,0 +1,23 @@ +# +# A ConfigMap spec for openldap slapd that map directly to files under +# /container/service/slapd/assets/config/bootstrap/ldif/custom +# +{{- if .Values.customLdifFiles }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ template "openldap.fullname" . }}-customldif + labels: + app: {{ template "openldap.name" . }} + chart: {{ template "openldap.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +{{- if .Values.extraLabels }} +{{ toYaml .Values.extraLabels | indent 4 }} +{{- end }} +data: +{{- range $key, $val := .Values.customLdifFiles }} + {{ $key }}: |- +{{ $val | indent 4}} +{{- end }} +{{- end }} diff --git a/docker/mongodb-enterprise-tests/vendor/openldap/templates/configmap-env.yaml b/docker/mongodb-enterprise-tests/vendor/openldap/templates/configmap-env.yaml new file mode 100644 index 000000000..d8fe9a497 --- /dev/null +++ b/docker/mongodb-enterprise-tests/vendor/openldap/templates/configmap-env.yaml @@ -0,0 +1,20 @@ +# +# A ConfigMap spec for openldap slapd that map directly to env variables in the Pod. +# List of environment variables supported is from the docker image: +# https://github.com/osixia/docker-openldap#beginner-guide +# Note that passwords are defined as secrets +# +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ template "openldap.fullname" . }}-env + labels: + app: {{ template "openldap.name" . }} + chart: {{ template "openldap.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +{{- if .Values.extraLabels }} +{{ toYaml .Values.extraLabels | indent 4 }} +{{- end }} +data: +{{ toYaml .Values.env | indent 2 }} diff --git a/docker/mongodb-enterprise-tests/vendor/openldap/templates/deployment.yaml b/docker/mongodb-enterprise-tests/vendor/openldap/templates/deployment.yaml new file mode 100644 index 000000000..a5847f198 --- /dev/null +++ b/docker/mongodb-enterprise-tests/vendor/openldap/templates/deployment.yaml @@ -0,0 +1,174 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ template "openldap.fullname" . }} + labels: + app: {{ template "openldap.name" . }} + chart: {{ template "openldap.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +{{- if .Values.extraLabels }} +{{ toYaml .Values.extraLabels | indent 4 }} +{{- end }} +spec: + replicas: {{ .Values.replicaCount }} +{{- if .Values.strategy }} + strategy: +{{ toYaml .Values.strategy | indent 4 }} +{{- end }} + selector: + matchLabels: + app: {{ template "openldap.name" . }} + release: {{ .Release.Name }} + template: + metadata: + annotations: + checksum/configmap-env: {{ include (print $.Template.BasePath "/configmap-env.yaml") . | sha256sum }} +{{- if .Values.customLdifFiles}} + checksum/configmap-customldif: {{ include (print $.Template.BasePath "/configmap-customldif.yaml") . | sha256sum }} +{{- end }} +{{- if .Values.podAnnotations}} +{{ toYaml .Values.podAnnotations | indent 8}} +{{- end }} + labels: + app: {{ template "openldap.name" . }} + release: {{ .Release.Name }} + spec: + {{- if or .Values.customLdifFiles .Values.tls.enabled }} + initContainers: + {{- end }} + {{- if .Values.customLdifFiles }} + - name: {{ .Chart.Name }}-init-ldif + image: busybox + command: ['sh', '-c', 'cp /customldif/* /ldifworkingdir'] + imagePullPolicy: {{ .Values.image.pullPolicy }} + volumeMounts: + - name: customldif + mountPath: /customldif + - name: ldifworkingdir + mountPath: /ldifworkingdir + resources: +{{ toYaml .Values.initResources | indent 10 }} + {{- end }} + {{- if .Values.tls.enabled }} + - name: {{ .Chart.Name }}-init-tls + image: busybox + command: ['sh', '-c', 'cp /tls/* /certs'] + imagePullPolicy: {{ .Values.image.pullPolicy }} + volumeMounts: + - name: tls + mountPath: /tls + - name: certs + mountPath: /certs + resources: +{{ toYaml .Values.initResources | indent 10 }} + {{- if .Values.tls.CA.enabled }} + - name: {{ .Chart.Name }}-init-catls + image: busybox + command: ['sh', '-c', 'cp /catls/ca.crt /certs'] + volumeMounts: + - name: catls + mountPath: /catls + - name: certs + mountPath: /certs + resources: +{{ toYaml .Values.initResources | indent 10 }} + {{- end }} + {{- end }} + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} +{{- if .Values.customLdifFiles }} + args: [--copy-service] +{{- end }} + ports: + - name: ldap-port + containerPort: 389 + - name: ssl-ldap-port + containerPort: 636 + envFrom: + - configMapRef: + name: {{ template "openldap.fullname" . }}-env + - secretRef: + name: {{ template "openldap.secretName" . }} + volumeMounts: + - name: data + mountPath: /var/lib/ldap + subPath: data + - name: data + mountPath: /etc/ldap/slapd.d + subPath: config-data + {{- if .Values.customLdifFiles }} + - name: ldifworkingdir + mountPath: /container/service/slapd/assets/config/bootstrap/ldif/custom + {{- end }} + {{- if .Values.tls.enabled }} + - name: certs + mountPath: /container/service/slapd/assets/certs + {{- end }} + env: + {{- if .Values.tls.enabled }} + - name: LDAP_TLS_CRT_FILENAME + value: tls.crt + - name: LDAP_TLS_KEY_FILENAME + value: tls.key + {{- if .Values.tls.CA.enabled }} + - name: LDAP_TLS_CA_CRT_FILENAME + value: ca.crt + {{- end }} + {{- end }} + livenessProbe: + tcpSocket: + port: ldap-port + initialDelaySeconds: 20 + periodSeconds: 10 + failureThreshold: 10 + readinessProbe: + tcpSocket: + port: ldap-port + initialDelaySeconds: 20 + periodSeconds: 10 + failureThreshold: 10 + resources: +{{ toYaml .Values.resources | indent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: +{{ toYaml . | indent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: +{{ toYaml . | indent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: +{{ toYaml . | indent 8 }} + {{- end }} + volumes: + {{- if .Values.customLdifFiles }} + - name: customldif + configMap: + name: {{ template "openldap.fullname" . }}-customldif + - name: ldifworkingdir + emptyDir: {} + {{- end }} + {{- if .Values.tls.enabled }} + - name: tls + secret: + secretName: {{ .Values.tls.secret }} + {{- if .Values.tls.CA.enabled }} + - name: catls + secret: + secretName: {{ .Values.tls.CA.secret }} + {{- end }} + {{- end }} + - name: certs + emptyDir: + medium: Memory + - name: data + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ .Values.persistence.existingClaim | default (include "openldap.fullname" .) }} + {{- else }} + emptyDir: {} + {{- end -}} diff --git a/docker/mongodb-enterprise-tests/vendor/openldap/templates/pvc.yaml b/docker/mongodb-enterprise-tests/vendor/openldap/templates/pvc.yaml new file mode 100644 index 000000000..96d6c8680 --- /dev/null +++ b/docker/mongodb-enterprise-tests/vendor/openldap/templates/pvc.yaml @@ -0,0 +1,27 @@ +{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }} +kind: PersistentVolumeClaim +apiVersion: v1 +metadata: + name: {{ template "openldap.fullname" . }} + labels: + app: {{ template "openldap.name" . }} + chart: {{ template "openldap.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +{{- if .Values.extraLabels }} +{{ toYaml .Values.extraLabels | indent 4 }} +{{- end }} +spec: + accessModes: + - {{ .Values.persistence.accessMode | quote }} + resources: + requests: + storage: {{ .Values.persistence.size | quote }} +{{- if .Values.persistence.storageClass }} +{{- if (eq "-" .Values.persistence.storageClass) }} + storageClassName: "" +{{- else }} + storageClassName: "{{ .Values.persistence.storageClass }}" +{{- end }} +{{- end }} +{{- end }} diff --git a/docker/mongodb-enterprise-tests/vendor/openldap/templates/secret.yaml b/docker/mongodb-enterprise-tests/vendor/openldap/templates/secret.yaml new file mode 100644 index 000000000..9c7953acc --- /dev/null +++ b/docker/mongodb-enterprise-tests/vendor/openldap/templates/secret.yaml @@ -0,0 +1,18 @@ +{{ if not .Values.existingSecret }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ template "openldap.fullname" . }} + labels: + app: {{ template "openldap.name" . }} + chart: {{ template "openldap.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +{{- if .Values.extraLabels }} +{{ toYaml .Values.extraLabels | indent 4 }} +{{- end }} +type: Opaque +data: + LDAP_ADMIN_PASSWORD: {{ .Values.adminPassword | default (randAlphaNum 32) | b64enc | quote }} + LDAP_CONFIG_PASSWORD: {{ .Values.configPassword | default (randAlphaNum 32) | b64enc | quote }} +{{ end }} diff --git a/docker/mongodb-enterprise-tests/vendor/openldap/templates/service.yaml b/docker/mongodb-enterprise-tests/vendor/openldap/templates/service.yaml new file mode 100644 index 000000000..e1bb2d397 --- /dev/null +++ b/docker/mongodb-enterprise-tests/vendor/openldap/templates/service.yaml @@ -0,0 +1,44 @@ +apiVersion: v1 +kind: Service +metadata: +{{- if .Values.service.annotations }} + annotations: +{{ toYaml .Values.service.annotations | indent 4 }} +{{- end }} + name: {{ template "openldap.fullname" . }} + labels: + app: {{ template "openldap.name" . }} + chart: {{ template "openldap.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +{{- if .Values.extraLabels }} +{{ toYaml .Values.extraLabels | indent 4 }} +{{- end }} +spec: + {{- with .Values.service.clusterIP }} + clusterIP: {{ . | quote }} + {{- end }} +{{- if .Values.service.externalIPs }} + externalIPs: +{{ toYaml .Values.service.externalIPs | indent 4 }} +{{- end }} +{{- if .Values.service.loadBalancerIP }} + loadBalancerIP: {{ .Values.service.loadBalancerIP | quote }} +{{- end }} +{{- if .Values.service.loadBalancerSourceRanges }} + loadBalancerSourceRanges: +{{ toYaml .Values.service.loadBalancerSourceRanges | indent 4 }} +{{- end }} + ports: + - name: ldap-port + protocol: TCP + port: {{ .Values.service.ldapPort }} + targetPort: ldap-port + - name: ssl-ldap-port + protocol: TCP + port: {{ .Values.service.sslLdapPort }} + targetPort: ssl-ldap-port + selector: + app: {{ template "openldap.name" . }} + release: {{ .Release.Name }} + type: {{ .Values.service.type }} diff --git a/docker/mongodb-enterprise-tests/vendor/openldap/templates/tests/openldap-test-runner.yaml b/docker/mongodb-enterprise-tests/vendor/openldap/templates/tests/openldap-test-runner.yaml new file mode 100644 index 000000000..cfcaf2183 --- /dev/null +++ b/docker/mongodb-enterprise-tests/vendor/openldap/templates/tests/openldap-test-runner.yaml @@ -0,0 +1,50 @@ +{{- if .Values.test.enabled -}} +apiVersion: v1 +kind: Pod +metadata: + name: "{{ template "openldap.fullname" . }}-test-{{ randAlphaNum 5 | lower }}" + labels: + app: {{ template "openldap.name" . }} + chart: {{ template "openldap.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +{{- if .Values.extraLabels }} +{{ toYaml .Values.extraLabels | indent 4 }} +{{- end }} + annotations: + "helm.sh/hook": test-success +spec: + initContainers: + - name: test-framework + image: {{ .Values.test.image.repository }}:{{ .Values.test.image.tag }} + command: + - "bash" + - "-c" + - | + set -ex + # copy bats to tools dir + cp -R /usr/local/libexec/ /tools/bats/ + volumeMounts: + - mountPath: /tools + name: tools + containers: + - name: {{ .Release.Name }}-test + image: {{ .Values.test.image.repository }}:{{ .Values.test.image.tag }} + envFrom: + - secretRef: + name: {{ template "openldap.secretName" . }} + command: ["/tools/bats/bats", "-t", "/tests/run.sh"] + volumeMounts: + - mountPath: /tests + name: tests + readOnly: true + - mountPath: /tools + name: tools + volumes: + - name: tests + configMap: + name: {{ template "openldap.fullname" . }}-tests + - name: tools + emptyDir: {} + restartPolicy: Never +{{- end -}} diff --git a/docker/mongodb-enterprise-tests/vendor/openldap/templates/tests/openldap-tests.yaml b/docker/mongodb-enterprise-tests/vendor/openldap/templates/tests/openldap-tests.yaml new file mode 100644 index 000000000..1cdeb80b7 --- /dev/null +++ b/docker/mongodb-enterprise-tests/vendor/openldap/templates/tests/openldap-tests.yaml @@ -0,0 +1,22 @@ +{{- if .Values.test.enabled -}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ template "openldap.fullname" . }}-tests + labels: + app: {{ template "openldap.name" . }} + chart: {{ template "openldap.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +{{- if .Values.extraLabels }} +{{ toYaml .Values.extraLabels | indent 4 }} +{{- end }} +data: + run.sh: |- + @test "Testing connecting to slapd server" { + # Ideally, this should be in the docker image, but there is not a generic image we can use + # with bats and ldap-utils installed. It is not worth for now to push an image for this. + apt-get update && apt-get install -y ldap-utils + ldapsearch -x -H ldap://{{ template "openldap.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local:{{ .Values.service.ldapPort }} -b "dc=example,dc=org" -D "cn=admin,dc=example,dc=org" -w $LDAP_ADMIN_PASSWORD + } +{{- end -}} diff --git a/docker/mongodb-enterprise-tests/vendor/openldap/values.yaml b/docker/mongodb-enterprise-tests/vendor/openldap/values.yaml new file mode 100644 index 000000000..7779a539f --- /dev/null +++ b/docker/mongodb-enterprise-tests/vendor/openldap/values.yaml @@ -0,0 +1,117 @@ +# Default values for openldap. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +# Define deployment strategy - IMPORTANT: use rollingUpdate: null when use Recreate strategy. +# It prevents from merging with existing map keys which are forbidden. +strategy: {} + # type: RollingUpdate + # rollingUpdate: + # maxSurge: 1 + # maxUnavailable: 0 + # + # or + # + # type: Recreate + # rollingUpdate: null +image: + # From repository https://github.com/osixia/docker-openldap + repository: osixia/openldap + + tag: 1.2.5 + pullPolicy: IfNotPresent + +# Spcifies an existing secret to be used for admin and config user passwords +existingSecret: "" + +# settings for enabling TLS +tls: + enabled: false + secret: "" # The name of a kubernetes.io/tls type secret to use for TLS + CA: + enabled: false + secret: "" # The name of a generic secret to use for custom CA certificate (ca.crt) +## Add additional labels to all resources +extraLabels: {} +## Add additional annotations to pods +podAnnotations: {} +service: + annotations: {} + + ldapPort: 389 + sslLdapPort: 636 # Only used if tls.enabled is true + ## List of IP addresses at which the service is available + ## Ref: https://kubernetes.io/docs/user-guide/services/#external-ips + ## + externalIPs: [] + + loadBalancerIP: "" + loadBalancerSourceRanges: [] + type: ClusterIP + +# Default configuration for openldap as environment variables. These get injected directly in the container. +# Use the env variables from https://github.com/osixia/docker-openldap#beginner-guide +env: + LDAP_ORGANISATION: "Example Inc." + LDAP_DOMAIN: "example.org" + LDAP_BACKEND: "hdb" + LDAP_TLS: "true" + LDAP_TLS_ENFORCE: "false" + LDAP_REMOVE_CONFIG_AFTER_SETUP: "true" + +# Default Passwords to use, stored as a secret. If unset, passwords are auto-generated. +# You can override these at install time with +# helm install openldap --set openldap.adminPassword=,openldap.configPassword= +# adminPassword: admin +# configPassword: config + +# Custom openldap configuration files used to override default settings +# customLdifFiles: + # 01-default-users.ldif: |- + # Predefine users here + +## Persist data to a persistent volume +persistence: + enabled: false + ## database data Persistent Volume Storage Class + ## If defined, storageClassName: + ## If set to "-", storageClassName: "", which disables dynamic provisioning + ## If undefined (the default) or set to null, no storageClassName spec is + ## set, choosing the default provisioner. (gp2 on AWS, standard on + ## GKE, AWS & OpenStack) + ## + # storageClass: "-" + accessMode: ReadWriteOnce + size: 8Gi + # existingClaim: "" + +resources: {} + # requests: + # cpu: "100m" + # memory: "256Mi" + # limits: + # cpu: "500m" + # memory: "512Mi" + +initResources: {} + # requests: + # cpu: "100m" + # memory: "128Mi" + # limits: + # cpu: "100m" + # memory: "128Mi" + +nodeSelector: {} + +tolerations: [] + +affinity: {} + +## test container details +test: + enabled: false + image: + repository: dduportal/bats + tag: 0.4.0 diff --git a/docs/investigation/pod-is-killed-while-agent-restores.md b/docs/investigation/pod-is-killed-while-agent-restores.md new file mode 100644 index 000000000..3254bcee6 --- /dev/null +++ b/docs/investigation/pod-is-killed-while-agent-restores.md @@ -0,0 +1,208 @@ +# Repro Pod is Killed During a Restore Operation + +## Summary + +In the middle of an automated database restore, customers found that the Pod was +being killed after 1 hour. They had to manually change the liveness probe to +increase the tolerance to many hours. + +It has been confirmed by the automation team that the Agent, when starting a +restore operation, will stop the database and download the restore files, copy +new files to /data and start the mongodb database once again. **During this +period the mongod process is down**, this is, there's no `PID` associated with +it, making the `Livenessprobe` to flag the Pods, causing a Pod restart. + +## References + +* https://jira.mongodb.org/browse/CLOUDP-84518 +* https://jira.mongodb.org/browse/CLOUDP-80199 + +## Materials + +In order to reproduce this scenario we need: + +- An Ops Manager instance with Backup configured +- Some way of pushing a reasonable amount of data + to a Replica set + +In this document, we describe how to do both. + +# Ops Manager Configuration + +The best possible alternative is to run the test `e2e_om_ops_manager_backup` +locally over your own cluster. This cluster should have enough capacity to hold: + ++ 1 Ops Manager instance ++ 1 Backup daemon ++ 3 Backup Replica Sets (blockstore, oplog and S3) ++ 1 AppDB Replica Set ++ 1 Test Replica Set (the one we'll fill with data and try to restore) + +## Set up Ops Manager with an E2E Test + + +Just execute the test with the test with the usual: + +```shell +make e2e test=e2e_om_ops_manager_backup_manual light=true +``` + +You'll have to wait around 20 minutes for this to finish. + +## Get a Database Dump to push to our new Replica Set + +Get the dump from S3 with: + +```shell +aws s3 cp s3://cloudp-84518/atlas-sample-database.zip . +``` + +And unzip: + +```shell +unzip atlas-sample-database.zip +``` + +The dump was obtained from Atlas, after loading a [sample +database](https://docs.atlas.mongodb.com/sample-data/) to it. + +## Upload data to the Running Database + +This will get a bit hacky, we'll restore the same dump *mutiple times*, into +databases with different names. + +1. Start a new Pod and download MongoDB Tools into it: + +``` +kubectl run mongothings -n mongodb --image ubuntu -i --tty --rm bash +apt update && apt install curl -y +curl -L https://fastdl.mongodb.org/tools/db/mongodb-database-tools-ubuntu2004-x86_64-100.3.1.tgz -o /tmp/tools.tgz +tar xfz /tmp/tools.tgz +``` + +2. In a different shell, copy dump into Pod (takes like 2 minutes) + +``` +kubectl cp dump mongodb/mongothings:/tmp +``` + +3. Now go back to the other Pod and fill up the database. Each 10 iterations will fill the disk with about 3.5G + +``` +# 10 iterations will be about ~3.5 G +iterations=10 +mrestore="/mongodb-database-tools-ubuntu2004-x86_64-100.3.1/bin/mongorestore" +host="rs-non-fixed-0.rs-non-fixed-svc.mongodb.svc.cluster.local" +cd /tmp/dump + +for i in $(seq 1 $iterations); do + mkdir /tmp/dump-$i + for dir in *; do cp -r $dir "/tmp/dump-$i/$dir-$i"; done + + "${mrestore}" --host "${host}" /tmp/dump-$i + + rm -rf /tmp/dump-$i +done + +``` + + +# Backup & Restore + +## Enable Continuous Backup + +The `rs-fixed` and `rs-non-fixed` will have *Continuous Backup* enabled by this +point. We'll push some data into the database to cause a future restore to make +the database to fail. + +10 copies of the Atlas sample database generate a restore of around 7GB, then it +takes more than 3 minutes to `MakeBackupDataAvailable`. Which means that the Pod +will be restarted because of the LivenessProbe. + +``` +$ kubectl get pods +NAME READY STATUS RESTARTS AGE +rs-non-fixed-0 1/1 Running 0 14m +rs-non-fixed-1 1/1 Running 0 14m +rs-non-fixed-2 1/1 Running 0 14m +``` + +For this investigation, we use 3 Pods; to make this easier to reproduce, we'll +wait for 60 minutes before starting the tests. + +Downloading the Backup file takes a long time (it is several GBs in size), and +this helps with the investigation. + +After some time, the Pods that are younger than 60 minutes get to running (and +Alive) state, but not the one that's older than 60 minutes. In order to fix it +I will proceed with the solution in next paragraph. + +## Pods keep being restarted + +The Pods, because they can't download the full 7GB of restore in less than 3 +minutes will be restarted, causing the whole process to start from scratch: the +agent has to be downloaded again, get the automation config, and start +downloading the restore archive. + +## How does the LivenessProbe works in this case + +The `LivenessProbe` is configured as follows: + +```golang +func databaseLivenessProbe() probes.Modification { + return probes.Apply( + probes.WithExecCommand([]string{databaseLivenessProbeCommand}), + probes.WithInitialDelaySeconds(60), + probes.WithTimeoutSeconds(30), + probes.WithPeriodSeconds(30), + probes.WithSuccessThreshold(1), + probes.WithFailureThreshold(6), + ) +} +``` + +The `LivenessProbe` has a 60 minutes tolerance to the absence of `mongod`, +`mongos` and `agent` processes. If we wait until the Pods are older than 1 hour, +and because the `LivenessProbe` is configured to fail after 6 tries +(`FailureThreshold`) and to probe every 30 seconds (`PeriodSeconds`), then the +Pods will be flag as failed after ~180 seconds. + +When describing one of the Pods we get: + +``` +Events: + Type Reason Age From Message + ---- ------ ---- ---- ------- + Warning Unhealthy 2m54s (x171 over 3h28m) kubelet Readiness probe failed: + Warning Unhealthy 2m53s (x10 over 162m) kubelet Liveness probe failed: + +``` + +And the Pods will report multiple restarts: + +```shell +kubectl get pods +NAME READY STATUS RESTARTS AGE +rs-non-fixed-0 0/1 Running 2 6h1m +rs-non-fixed-1 0/1 Running 1 3h19m +rs-non0fixed-2 0/1 Running 1 79m +``` + +# Solution + +If the `livenessProbe` is modified, to consider the agent process to be running, +as a sufficient condition for the Pod to be considered alive. The restore +operation succeeds after a reasonable amount of time. + +In this scenario, 2 MongoDB objects are provided: + +* `rs-fixed`: Uses a *modified* version of the `probe.sh` which will accept the + Agent's PID as sufficient condition for a Pod to be alive. +* `rs-non-fixed`: Uses the *regular* version of `probe.sh`. + + +`rs-fixed` survives a restore of a 3G snapshot, but `rs-non-fixed` does not. + +Both are identical, but `rs-fixed` has been configure with a special +LivenessProbe with the proposed fix, while `rs-non-fixed` is using the +LivenessProbe part of the Operator 1.10. diff --git a/go.mod b/go.mod new file mode 100644 index 000000000..ef8d88995 --- /dev/null +++ b/go.mod @@ -0,0 +1,128 @@ +// try to always update patch versions with `go get -u=patch ./...` + +module github.com/10gen/ops-manager-kubernetes + +require ( + cloud.google.com/go v0.110.2 + github.com/aws/aws-sdk-go v1.44.261 + github.com/blang/semver v3.5.1+incompatible + github.com/evanphx/json-patch v5.6.0+incompatible + github.com/ghodss/yaml v1.0.0 + github.com/go-logr/logr v1.2.3 + github.com/google/go-cmp v0.5.9 + github.com/google/uuid v1.3.0 + github.com/hashicorp/go-multierror v1.1.1 + github.com/hashicorp/go-retryablehttp v0.7.2 + github.com/hashicorp/vault/api v1.8.3 + github.com/imdario/mergo v0.3.15 + github.com/mongodb/mongodb-kubernetes-operator v0.8.1-0.20230524141203-f3647e30aa46 + github.com/pkg/errors v0.9.1 + github.com/prometheus/client_golang v1.14.0 + github.com/prometheus/common v0.39.0 + github.com/r3labs/diff/v3 v3.0.1 + github.com/spf13/cast v1.5.1 + github.com/spf13/pflag v1.0.5 + github.com/stretchr/objx v0.5.0 + github.com/stretchr/testify v1.8.2 + github.com/xdg/stringprep v1.0.3 + go.uber.org/zap v1.24.0 + golang.org/x/crypto v0.7.0 + golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 + k8s.io/api v0.24.14 + k8s.io/apimachinery v0.24.14 + k8s.io/client-go v0.24.14 + k8s.io/code-generator v0.24.14 + k8s.io/utils v0.0.0-20220210201930-3a6ce19ff2f9 + sigs.k8s.io/controller-runtime v0.12.3 +) + +require ( + github.com/PuerkitoBio/purell v1.1.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect + github.com/armon/go-metrics v0.3.9 // indirect + github.com/armon/go-radix v1.0.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v3 v3.0.0 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful v2.9.6+incompatible // indirect + github.com/fatih/color v1.7.0 // indirect + github.com/fsnotify/fsnotify v1.5.1 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.19.5 // indirect + github.com/go-openapi/swag v0.19.14 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/google/gnostic v0.5.7-v3refs // indirect + github.com/google/gofuzz v1.1.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-hclog v0.16.2 // indirect + github.com/hashicorp/go-immutable-radix v1.3.1 // indirect + github.com/hashicorp/go-plugin v1.4.5 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/mlock v0.1.1 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.2 // indirect + github.com/hashicorp/go-uuid v1.0.2 // indirect + github.com/hashicorp/go-version v1.2.0 // indirect + github.com/hashicorp/golang-lru v0.5.4 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/hashicorp/vault/sdk v0.7.0 // indirect + github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.6 // indirect + github.com/mattn/go-colorable v0.1.6 // indirect + github.com/mattn/go-isatty v0.0.12 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/mitchellh/copystructure v1.0.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/go-testing-interface v1.0.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mitchellh/reflectwalk v1.0.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oklog/run v1.0.0 // indirect + github.com/pierrec/lz4 v2.5.2+incompatible // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_model v0.3.0 // indirect + github.com/prometheus/procfs v0.8.0 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.6.0 // indirect + golang.org/x/mod v0.8.0 // indirect + golang.org/x/net v0.9.0 // indirect + golang.org/x/oauth2 v0.7.0 // indirect + golang.org/x/sys v0.7.0 // indirect + golang.org/x/term v0.7.0 // indirect + golang.org/x/text v0.9.0 // indirect + golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 // indirect + golang.org/x/tools v0.6.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.2.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect + google.golang.org/grpc v1.55.0 // indirect + google.golang.org/protobuf v1.30.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/square/go-jose.v2 v2.5.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/apiextensions-apiserver v0.24.14 // indirect + k8s.io/component-base v0.24.14 // indirect + k8s.io/gengo v0.0.0-20211129171323-c02415ce4185 // indirect + k8s.io/klog/v2 v2.60.1 // indirect + k8s.io/kube-openapi v0.0.0-20220328201542-3ee0da9b0b42 // indirect + sigs.k8s.io/json v0.0.0-20211208200746-9f7c6b3444d2 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect + sigs.k8s.io/yaml v1.3.0 // indirect +) + +go 1.20 diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..201dbb443 --- /dev/null +++ b/go.sum @@ -0,0 +1,518 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.110.2 h1:sdFPBr6xG9/wkBbfhmUz/JmZC7X6LavQgcrVINrKiVA= +cloud.google.com/go v0.110.2/go.mod h1:k04UEeEtb6ZBRTv3dZz4CeJC3jKGxyhl0sAiVVquxiw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/armon/go-metrics v0.3.9 h1:O2sNqxBdvq8Eq5xmzljcYzAORli6RWCvEym4cJf9m18= +github.com/armon/go-metrics v0.3.9/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= +github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= +github.com/aws/aws-sdk-go v1.44.261 h1:PcTMX/QVk+P3yh2n34UzuXDF5FS2z5Lse2bt+r3IpU4= +github.com/aws/aws-sdk-go v1.44.261/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/cenkalti/backoff/v3 v3.0.0 h1:ske+9nBpD9qZsTBoF41nW5L+AIuFBKMeze18XQ3eG1c= +github.com/cenkalti/backoff/v3 v3.0.0/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= +github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= +github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= +github.com/emicklei/go-restful v2.9.6+incompatible h1:tfrHha8zJ01ywiOEC1miGY8st1/igzWB8OmvPgoYX7w= +github.com/emicklei/go-restful v2.9.6+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U= +github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= +github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI= +github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= +github.com/getkin/kin-openapi v0.76.0/go.mod h1:660oXbgy5JFMKreazJaQTw7o+X00qeSyhcnluiMv+Xg= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= +github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= +github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= +github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/zapr v1.2.0 h1:n4JnPI1T3Qq1SFEi/F8rwLrZERp2bso19PJZDB9dayk= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8= +github.com/go-openapi/jsonreference v0.19.5 h1:1WJP/wi4OjB4iV8KVbH73rQaoialJrqv8gitZLxGLtM= +github.com/go-openapi/jsonreference v0.19.5/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.14 h1:gm3vOOXfiuw5i9p5N9xJvfjvuofpyvLA9Wr6QfK5Fng= +github.com/go-openapi/swag v0.19.14/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/gnostic v0.5.7-v3refs h1:FhTMOKj2VhjpouxvWJAV1TL304uMlb9zcDqkl6cEI54= +github.com/google/gnostic v0.5.7-v3refs/go.mod h1:73MKFl6jIHelAJNaBGFzt3SPtZULs9dYrGFt8OiIsHQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g= +github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= +github.com/hashicorp/go-hclog v0.16.2 h1:K4ev2ib4LdQETX5cSZBG0DVLk1jwGqSPXBjdah3veNs= +github.com/hashicorp/go-hclog v0.16.2/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-plugin v1.4.5 h1:oTE/oQR4eghggRg8VY7PAz3dr++VwDNBGCcOfIvHpBo= +github.com/hashicorp/go-plugin v1.4.5/go.mod h1:viDMjcLJuDui6pXb8U4HVfb8AamCWhHGUjr2IrTF67s= +github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-retryablehttp v0.7.2 h1:AcYqCvkpalPnPF2pn0KamgwamS42TqUDDYFRKq/RAd0= +github.com/hashicorp/go-retryablehttp v0.7.2/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/mlock v0.1.1 h1:cCRo8gK7oq6A2L6LICkUZ+/a5rLiRXFMf1Qd4xSwxTc= +github.com/hashicorp/go-secure-stdlib/mlock v0.1.1/go.mod h1:zq93CJChV6L9QTfGKtfBxKqD7BqqXx5O04A/ns2p5+I= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 h1:om4Al8Oy7kCm/B86rLCLah4Dt5Aa0Fr5rYBG60OzwHQ= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6/go.mod h1:QmrqtbKuxxSWTN3ETMPuB+VtEiBJ/A9XhoYGv8E1uD8= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.1/go.mod h1:gKOamz3EwoIoJq7mlMIRBpVTAUn8qPCrEclOKKWhD3U= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= +github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.2.0 h1:3vNe/fWF5CBgRIguda1meWhsZHy3m8gCJ5wx+dIzX/E= +github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/vault/api v1.8.3 h1:cHQOLcMhBR+aVI0HzhPxO62w2+gJhIrKguQNONPzu6o= +github.com/hashicorp/vault/api v1.8.3/go.mod h1:4g/9lj9lmuJQMtT6CmVMHC5FW1yENaVv+Nv4ZfG8fAg= +github.com/hashicorp/vault/sdk v0.7.0 h1:2pQRO40R1etpKkia5fb4kjrdYMx3BHklPxl1pxpxDHg= +github.com/hashicorp/vault/sdk v0.7.0/go.mod h1:KyfArJkhooyba7gYCKSq8v66QdqJmnbAxtV/OX1+JTs= +github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb h1:b5rjCoWHc7eqmAS4/qyk21ZsHyb6Mxv/jykxvNTkU4M= +github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= +github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM= +github.com/imdario/mergo v0.3.15/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jhump/protoreflect v1.6.0 h1:h5jfMVslIg6l29nsMs0D8Wj17RDVdNYti0vDN/PZZoE= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= +github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ= +github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-testing-interface v1.0.0 h1:fzU/JVNcaqHQEcVFAKeR41fkiLdIPrefOvVG1VZ96U0= +github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY= +github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mongodb/mongodb-kubernetes-operator v0.8.1-0.20230524141203-f3647e30aa46 h1:Q/7sNsP7zo0jwIye6CFs0ktgUEl7l6vS1oxJeRIOA8o= +github.com/mongodb/mongodb-kubernetes-operator v0.8.1-0.20230524141203-f3647e30aa46/go.mod h1:4BZV3IUiD1QXCEJ8j+ohd5wSa7X29E/0cpU0HN6papQ= +github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= +github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= +github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= +github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pierrec/lz4 v2.5.2+incompatible h1:WCjObylUIOlKy/+7Abdn34TLIkXiA4UWUMhxq9m9ZXI= +github.com/pierrec/lz4 v2.5.2+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw= +github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= +github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= +github.com/prometheus/common v0.39.0 h1:oOyhkDq05hPZKItWVBkJ6g6AtGxi+fy7F4JvUV8uhsI= +github.com/prometheus/common v0.39.0/go.mod h1:6XBZ7lYdLCbkAVhwRsWTZn+IN5AB9F/NXd5w0BbEX0Y= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo= +github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4= +github.com/r3labs/diff/v3 v3.0.1 h1:CBKqf3XmNRHXKmdU7mZP1w7TV0pDyVCis1AUHtA4Xtg= +github.com/r3labs/diff/v3 v3.0.1/go.mod h1:f1S9bourRbiM66NskseyUdo0fTmEE0qKrikYJX63dgo= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= +github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA= +github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= +github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU= +github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/xdg/stringprep v1.0.3 h1:cmL5Enob4W83ti/ZHuZLuKD/xqJfus4fVPwE+/BDm+4= +github.com/xdg/stringprep v1.0.3/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= +go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= +go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= +golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.7.0 h1:qe6s0zUXlPX80/dITx3440hWZ7GwMwgDDyrSGTPJG/g= +golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.7.0 h1:BEvjmm5fURWqcfbSKTdpkDXYBrUS1c0m8agp14W48vQ= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44= +golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200505023115-26f46d2f7ef8/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +gomodules.xyz/jsonpatch/v2 v2.2.0 h1:4pT439QV83L+G9FkcCriY6EkpcK6r6bK+A5FBUMI7qY= +gomodules.xyz/jsonpatch/v2 v2.2.0/go.mod h1:WXp+iVDkoLQqPudfQ9GBlwB2eZ5DKOnjQZCYdOS8GPY= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20201019141844-1ed22bb0c154/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A= +google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.55.0 h1:3Oj82/tFSCeUrRTg/5E/7d/W5A1tj6Ky1ABAuZuv5ag= +google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w= +gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +k8s.io/api v0.24.14 h1:plWo5FZi1VJ7XC2NEeKyGS946e252vijDlqxeiN0cBk= +k8s.io/api v0.24.14/go.mod h1:dmyjYMJoi/FOIyH1RwYpgskcrl1RRmqsBfDVbB9VpqQ= +k8s.io/apiextensions-apiserver v0.24.14 h1:ktxuWE03e7yXj472uiJa009QQbnV+zLlJqzLQU/9OSM= +k8s.io/apiextensions-apiserver v0.24.14/go.mod h1:DwzZPn3zq6ooevBGEmEwA4yOMyfjmPtUYkU8Uc/o0YY= +k8s.io/apimachinery v0.24.14 h1:i7GrBju4O0onF1+jqXXPVmfXWilplxWYkTNU6G/h6Cs= +k8s.io/apimachinery v0.24.14/go.mod h1:Yyft+DTAvOmHyT332HkCMoTKroxYDEEx7NRLsdCYDoc= +k8s.io/client-go v0.24.14 h1:vwnWSAPLNN+IHi8yt08Q8InP71JXG5ix8YrBE32OOZU= +k8s.io/client-go v0.24.14/go.mod h1:/loTxPCTlfIOw1qAgzj7lGyFfXiHBPVWet+NB/+e2ho= +k8s.io/code-generator v0.24.14 h1:8CgCXQiwhJ3HJrkXsmOvLPNHIaE6sYksqYPJvFYhuIc= +k8s.io/code-generator v0.24.14/go.mod h1:nQvp6VgOfRkKiLyMz+/JTNXNS6Q4bGWOVtB5rKd2TV0= +k8s.io/component-base v0.24.14 h1:wKMSPRV1Ud8FByaOA6sE63iSEoOn299PjXAQel+6dEg= +k8s.io/component-base v0.24.14/go.mod h1:fvCLkVgILslt0LrXaPRyZal9A+uxs8FdMZb33IkSenA= +k8s.io/gengo v0.0.0-20210813121822-485abfe95c7c/go.mod h1:FiNAH4ZV3gBg2Kwh89tzAEV2be7d5xI0vBa/VySYy3E= +k8s.io/gengo v0.0.0-20211129171323-c02415ce4185 h1:TT1WdmqqXareKxZ/oNXEUSwKlLiHzPMyB0t8BaFeBYI= +k8s.io/gengo v0.0.0-20211129171323-c02415ce4185/go.mod h1:FiNAH4ZV3gBg2Kwh89tzAEV2be7d5xI0vBa/VySYy3E= +k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE= +k8s.io/klog/v2 v2.2.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= +k8s.io/klog/v2 v2.60.1 h1:VW25q3bZx9uE3vvdL6M8ezOX79vA2Aq1nEWLqNQclHc= +k8s.io/klog/v2 v2.60.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= +k8s.io/kube-openapi v0.0.0-20220328201542-3ee0da9b0b42 h1:Gii5eqf+GmIEwGNKQYQClCayuJCe2/4fZUvF7VG99sU= +k8s.io/kube-openapi v0.0.0-20220328201542-3ee0da9b0b42/go.mod h1:Z/45zLw8lUo4wdiUkI+v/ImEGAvu3WatcZl3lPMR4Rk= +k8s.io/utils v0.0.0-20210802155522-efc7438f0176/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= +k8s.io/utils v0.0.0-20220210201930-3a6ce19ff2f9 h1:HNSDgDCrr/6Ly3WEGKZftiE7IY19Vz2GdbOCyI4qqhc= +k8s.io/utils v0.0.0-20220210201930-3a6ce19ff2f9/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= +sigs.k8s.io/controller-runtime v0.12.3 h1:FCM8xeY/FI8hoAfh/V4XbbYMY20gElh9yh+A98usMio= +sigs.k8s.io/controller-runtime v0.12.3/go.mod h1:qKsk4WE6zW2Hfj0G4v10EnNB2jMG1C+NTb8h+DwCoU0= +sigs.k8s.io/json v0.0.0-20211208200746-9f7c6b3444d2 h1:kDi4JBNAsJWfz1aEXhO8Jg87JJaPNLh5tIzYHgStQ9Y= +sigs.k8s.io/json v0.0.0-20211208200746-9f7c6b3444d2/go.mod h1:B+TnT182UBxE84DiCz4CVE26eOSDAeYCpfDnC2kdKMY= +sigs.k8s.io/structured-merge-diff/v4 v4.0.2/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw= +sigs.k8s.io/structured-merge-diff/v4 v4.2.3 h1:PRbqxJClWWYMNV1dhaG4NsibJbArud9kFxnAMREiWFE= +sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E= +sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/hack/boilerplate.go.txt b/hack/boilerplate.go.txt new file mode 100644 index 000000000..45dbbbbcf --- /dev/null +++ b/hack/boilerplate.go.txt @@ -0,0 +1,15 @@ +/* +Copyright 2021. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ \ No newline at end of file diff --git a/helm_chart/Chart.yaml b/helm_chart/Chart.yaml new file mode 100644 index 000000000..a88820481 --- /dev/null +++ b/helm_chart/Chart.yaml @@ -0,0 +1,15 @@ +apiVersion: v2 +name: enterprise-operator +description: MongoDB Kubernetes Enterprise Operator +version: 1.20.0 +kubeVersion: '>=1.16-0' +type: application +keywords: +- mongodb +- database +- nosql +icon: https://mongodb-images-new.s3.eu-west-1.amazonaws.com/leaf-green-dark.png +home: https://github.com/mongodb/mongodb-enterprise-kubernetes +maintainers: +- name: MongoDB + email: support@mongodb.com diff --git a/helm_chart/crds/mongodb.com_mongodb.yaml b/helm_chart/crds/mongodb.com_mongodb.yaml new file mode 100644 index 000000000..62d2c29c5 --- /dev/null +++ b/helm_chart/crds/mongodb.com_mongodb.yaml @@ -0,0 +1,972 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.10.0 + creationTimestamp: null + name: mongodb.mongodb.com +spec: + group: mongodb.com + names: + kind: MongoDB + listKind: MongoDBList + plural: mongodb + shortNames: + - mdb + singular: mongodb + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Current state of the MongoDB deployment. + jsonPath: .status.phase + name: Phase + type: string + - description: Version of MongoDB server. + jsonPath: .status.version + name: Version + type: string + - description: The type of MongoDB deployment. One of 'ReplicaSet', 'ShardedCluster' + and 'Standalone'. + jsonPath: .spec.type + name: Type + type: string + - description: The time since the MongoDB resource was created. + jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + properties: + additionalMongodConfig: + description: 'AdditionalMongodConfig is additional configuration that + can be passed to each data-bearing mongod at runtime. Uses the same + structure as the mongod configuration file: https://docs.mongodb.com/manual/reference/configuration-options/' + type: object + x-kubernetes-preserve-unknown-fields: true + agent: + properties: + logLevel: + type: string + maxLogFileDurationHours: + type: integer + startupOptions: + additionalProperties: + type: string + type: object + type: object + backup: + description: Backup contains configuration options for configuring + backup for this MongoDB resource + properties: + assignmentLabels: + description: Assignment Labels set in the Ops Manager + items: + type: string + type: array + autoTerminateOnDeletion: + description: AutoTerminateOnDeletion indicates if the Operator + should stop and terminate the Backup before the cleanup, when + the MongoDB CR is deleted + type: boolean + encryption: + description: Encryption settings + properties: + kmip: + description: Kmip corresponds to the KMIP configuration assigned + to the Ops Manager Project's configuration. + properties: + client: + description: KMIP Client configuration + properties: + clientCertificatePrefix: + description: 'A prefix used to construct KMIP client + certificate (and corresponding password) Secret + names. The names are generated using the following + pattern: KMIP Client Certificate (TLS Secret): --kmip-client KMIP Client Certificate Password: + --kmip-client-password + The expected key inside is called "password".' + type: string + type: object + required: + - client + type: object + type: object + mode: + enum: + - enabled + - disabled + - terminated + type: string + snapshotSchedule: + properties: + clusterCheckpointIntervalMin: + enum: + - 15 + - 30 + - 60 + type: integer + dailySnapshotRetentionDays: + description: Number of days to retain daily snapshots. Setting + 0 will disable this rule. + maximum: 365 + minimum: 0 + type: integer + fullIncrementalDayOfWeek: + description: Day of the week when Ops Manager takes a full + snapshot. This ensures a recent complete backup. Ops Manager + sets the default value to SUNDAY. + enum: + - SUNDAY + - MONDAY + - TUESDAY + - WEDNESDAY + - THURSDAY + - FRIDAY + - SATURDAY + type: string + monthlySnapshotRetentionMonths: + description: Number of months to retain weekly snapshots. + Setting 0 will disable this rule. + maximum: 36 + minimum: 0 + type: integer + pointInTimeWindowHours: + description: Number of hours in the past for which a point-in-time + snapshot can be created. + enum: + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 15 + - 30 + - 60 + - 90 + - 120 + - 180 + - 360 + type: integer + referenceHourOfDay: + description: Hour of the day to schedule snapshots using a + 24-hour clock, in UTC. + maximum: 23 + minimum: 0 + type: integer + referenceMinuteOfHour: + description: Minute of the hour to schedule snapshots, in + UTC. + maximum: 59 + minimum: 0 + type: integer + snapshotIntervalHours: + description: Number of hours between snapshots. + enum: + - 6 + - 8 + - 12 + - 24 + type: integer + snapshotRetentionDays: + description: Number of days to keep recent snapshots. + maximum: 365 + minimum: 1 + type: integer + weeklySnapshotRetentionWeeks: + description: Number of weeks to retain weekly snapshots. Setting + 0 will disable this rule + maximum: 365 + minimum: 0 + type: integer + type: object + type: object + cloudManager: + properties: + configMapRef: + properties: + name: + type: string + type: object + type: object + clusterDomain: + format: hostname + type: string + configServerCount: + type: integer + configSrv: + properties: + additionalMongodConfig: + type: object + x-kubernetes-preserve-unknown-fields: true + agent: + properties: + logLevel: + type: string + maxLogFileDurationHours: + type: integer + startupOptions: + additionalProperties: + type: string + type: object + type: object + type: object + x-kubernetes-preserve-unknown-fields: true + configSrvPodSpec: + properties: + persistence: + properties: + multiple: + properties: + data: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + journal: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + logs: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + single: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + podTemplate: + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + connectivity: + properties: + replicaSetHorizons: + description: 'ReplicaSetHorizons holds list of maps of horizons + to be configured in each of MongoDB processes. Horizons map + horizon names to the node addresses for each process in the + replicaset, e.g.: [ { "internal": "my-rs-0.my-internal-domain.com:31843", + "external": "my-rs-0.my-external-domain.com:21467" }, { "internal": + "my-rs-1.my-internal-domain.com:31843", "external": "my-rs-1.my-external-domain.com:21467" + }, ... ] The key of each item in the map is an arbitrary, user-chosen + string that represents the name of the horizon. The value of + the item is the host and, optionally, the port that this mongod + node will be connected to from.' + items: + additionalProperties: + type: string + type: object + type: array + type: object + credentials: + description: Name of the Secret holding credentials information + type: string + exposedExternally: + description: 'DEPRECATED: use ExternalAccessConfiguration instead' + type: boolean + externalAccess: + description: ExternalAccessConfiguration provides external access + configuration. + properties: + externalDomain: + description: An external domain that is used for exposing MongoDB + to the outside world. + type: string + externalService: + description: Provides a way to override the default (NodePort) + Service + properties: + annotations: + additionalProperties: + type: string + description: A map of annotations that shall be added to the + externally available Service. + type: object + spec: + description: A wrapper for the Service spec object. + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + type: object + featureCompatibilityVersion: + type: string + logLevel: + enum: + - DEBUG + - INFO + - WARN + - ERROR + - FATAL + type: string + memberConfig: + description: MemberConfig + items: + properties: + priority: + type: string + tags: + additionalProperties: + type: string + type: object + votes: + type: integer + type: object + type: array + x-kubernetes-preserve-unknown-fields: true + members: + description: Amount of members for this MongoDB Replica Set + type: integer + mongodsPerShardCount: + type: integer + mongos: + properties: + additionalMongodConfig: + type: object + x-kubernetes-preserve-unknown-fields: true + agent: + properties: + logLevel: + type: string + maxLogFileDurationHours: + type: integer + startupOptions: + additionalProperties: + type: string + type: object + type: object + type: object + x-kubernetes-preserve-unknown-fields: true + mongosCount: + type: integer + mongosPodSpec: + properties: + persistence: + properties: + multiple: + properties: + data: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + journal: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + logs: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + single: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + podTemplate: + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + opsManager: + properties: + configMapRef: + properties: + name: + type: string + type: object + type: object + persistent: + type: boolean + podSpec: + properties: + persistence: + properties: + multiple: + properties: + data: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + journal: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + logs: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + single: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + podTemplate: + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + prometheus: + description: Prometheus configurations. + properties: + metricsPath: + description: Indicates path to the metrics endpoint. + pattern: ^\/[a-z0-9]+$ + type: string + passwordSecretRef: + description: Name of a Secret containing a HTTP Basic Auth Password. + properties: + key: + description: Key is the key in the secret storing this password. + Defaults to "password" + type: string + name: + description: Name is the name of the secret storing this user's + password + type: string + required: + - name + type: object + port: + description: Port where metrics endpoint will bind to. Defaults + to 9216. + type: integer + tlsSecretKeyRef: + description: Name of a Secret (type kubernetes.io/tls) holding + the certificates to use in the Prometheus endpoint. + properties: + key: + description: Key is the key in the secret storing this password. + Defaults to "password" + type: string + name: + description: Name is the name of the secret storing this user's + password + type: string + required: + - name + type: object + username: + description: HTTP Basic Auth Username for metrics endpoint. + type: string + required: + - passwordSecretRef + - username + type: object + security: + properties: + authentication: + description: Authentication holds various authentication related + settings that affect this MongoDB resource. + properties: + agents: + description: Agents contains authentication configuration + properties for the agents + properties: + automationLdapGroupDN: + type: string + automationPasswordSecretRef: + description: SecretKeySelector selects a key of a Secret. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, + uid?' + type: string + optional: + description: Specify whether the Secret or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + automationUserName: + type: string + clientCertificateSecretRef: + type: object + x-kubernetes-preserve-unknown-fields: true + mode: + description: Mode is the desired Authentication mode that + the agents will use + type: string + required: + - mode + type: object + enabled: + type: boolean + ignoreUnknownUsers: + description: IgnoreUnknownUsers maps to the inverse of auth.authoritativeSet + type: boolean + internalCluster: + type: string + ldap: + description: LDAP Configuration + properties: + authzQueryTemplate: + type: string + bindQueryPasswordSecretRef: + properties: + name: + type: string + required: + - name + type: object + bindQueryUser: + type: string + caConfigMapRef: + description: Allows to point at a ConfigMap/key with a + CA file to mount on the Pod + properties: + key: + description: The key to select. + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, + uid?' + type: string + optional: + description: Specify whether the ConfigMap or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + servers: + items: + type: string + type: array + timeoutMS: + type: integer + transportSecurity: + enum: + - tls + - none + type: string + userCacheInvalidationInterval: + type: integer + userToDNMapping: + type: string + validateLDAPServerConfig: + type: boolean + type: object + modes: + items: + type: string + type: array + requireClientTLSAuthentication: + description: Clients should present valid TLS certificates + type: boolean + required: + - enabled + type: object + certsSecretPrefix: + type: string + roles: + items: + properties: + authenticationRestrictions: + items: + properties: + clientSource: + items: + type: string + type: array + serverAddress: + items: + type: string + type: array + type: object + type: array + db: + type: string + privileges: + items: + properties: + actions: + items: + type: string + type: array + resource: + properties: + cluster: + type: boolean + collection: + type: string + db: + type: string + type: object + required: + - actions + - resource + type: object + type: array + role: + type: string + roles: + items: + properties: + db: + type: string + role: + type: string + required: + - db + - role + type: object + type: array + required: + - db + - role + type: object + type: array + tls: + properties: + additionalCertificateDomains: + items: + type: string + type: array + ca: + description: CA corresponds to a ConfigMap containing an entry + for the CA certificate (ca.pem) used to validate the certificates + created already. + type: string + enabled: + description: DEPRECATED please enable TLS by setting `security.certsSecretPrefix` + or `security.tls.secretRef.prefix`. Enables TLS for this + resource. This will make the operator try to mount a Secret + with a defined name (-cert). This is only + used when enabling TLS on a MongoDB resource, and not on + the AppDB, where TLS is configured by setting `secretRef.Name`. + type: boolean + type: object + type: object + service: + description: DEPRECATED please use `spec.statefulSet.spec.serviceName` + to provide a custom service name. this is an optional service, it + will get the name "-service" in case not provided + type: string + shard: + properties: + additionalMongodConfig: + type: object + x-kubernetes-preserve-unknown-fields: true + agent: + properties: + logLevel: + type: string + maxLogFileDurationHours: + type: integer + startupOptions: + additionalProperties: + type: string + type: object + type: object + type: object + x-kubernetes-preserve-unknown-fields: true + shardCount: + type: integer + shardPodSpec: + properties: + persistence: + properties: + multiple: + properties: + data: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + journal: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + logs: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + single: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + podTemplate: + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + shardSpecificPodSpec: + description: ShardSpecificPodSpec allows you to provide a Statefulset + override per shard. + items: + properties: + persistence: + properties: + multiple: + properties: + data: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + journal: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + logs: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + single: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + podTemplate: + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + type: array + statefulSet: + description: StatefulSetConfiguration provides the statefulset override + for each of the cluster's statefulset if "StatefulSetConfiguration" + is specified at cluster level under "clusterSpecList" that takes + precedence over the global one + properties: + spec: + type: object + x-kubernetes-preserve-unknown-fields: true + required: + - spec + type: object + type: + enum: + - Standalone + - ReplicaSet + - ShardedCluster + type: string + version: + pattern: ^[0-9]+.[0-9]+.[0-9]+(-.+)?$|^$ + type: string + required: + - credentials + - type + - version + type: object + x-kubernetes-preserve-unknown-fields: true + status: + properties: + backup: + properties: + statusName: + type: string + required: + - statusName + type: object + configServerCount: + type: integer + lastTransition: + type: string + link: + type: string + members: + type: integer + message: + type: string + mongodsPerShardCount: + type: integer + mongosCount: + type: integer + observedGeneration: + format: int64 + type: integer + phase: + type: string + resourcesNotReady: + items: + description: ResourceNotReady describes the dependent resource which + is not ready yet + properties: + errors: + items: + properties: + message: + type: string + reason: + type: string + type: object + type: array + kind: + description: ResourceKind specifies a kind of a Kubernetes resource. + Used in status of a Custom Resource + type: string + message: + type: string + name: + type: string + required: + - kind + - name + type: object + type: array + shardCount: + type: integer + version: + type: string + warnings: + items: + type: string + type: array + required: + - phase + - version + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/helm_chart/crds/mongodb.com_mongodbmulticluster.yaml b/helm_chart/crds/mongodb.com_mongodbmulticluster.yaml new file mode 100644 index 000000000..242028c27 --- /dev/null +++ b/helm_chart/crds/mongodb.com_mongodbmulticluster.yaml @@ -0,0 +1,754 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.10.0 + creationTimestamp: null + name: mongodbmulticluster.mongodb.com +spec: + group: mongodb.com + names: + kind: MongoDBMultiCluster + listKind: MongoDBMultiClusterList + plural: mongodbmulticluster + shortNames: + - mdbmc + singular: mongodbmulticluster + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Current state of the MongoDB deployment. + jsonPath: .status.phase + name: Phase + type: string + - description: The time since the MongoDBMultiCluster resource was created. + jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + properties: + additionalMongodConfig: + description: 'AdditionalMongodConfig is additional configuration that + can be passed to each data-bearing mongod at runtime. Uses the same + structure as the mongod configuration file: https://docs.mongodb.com/manual/reference/configuration-options/' + type: object + x-kubernetes-preserve-unknown-fields: true + agent: + properties: + logLevel: + type: string + maxLogFileDurationHours: + type: integer + startupOptions: + additionalProperties: + type: string + type: object + type: object + backup: + description: Backup contains configuration options for configuring + backup for this MongoDB resource + properties: + assignmentLabels: + description: Assignment Labels set in the Ops Manager + items: + type: string + type: array + autoTerminateOnDeletion: + description: AutoTerminateOnDeletion indicates if the Operator + should stop and terminate the Backup before the cleanup, when + the MongoDB CR is deleted + type: boolean + encryption: + description: Encryption settings + properties: + kmip: + description: Kmip corresponds to the KMIP configuration assigned + to the Ops Manager Project's configuration. + properties: + client: + description: KMIP Client configuration + properties: + clientCertificatePrefix: + description: 'A prefix used to construct KMIP client + certificate (and corresponding password) Secret + names. The names are generated using the following + pattern: KMIP Client Certificate (TLS Secret): --kmip-client KMIP Client Certificate Password: + --kmip-client-password + The expected key inside is called "password".' + type: string + type: object + required: + - client + type: object + type: object + mode: + enum: + - enabled + - disabled + - terminated + type: string + snapshotSchedule: + properties: + clusterCheckpointIntervalMin: + enum: + - 15 + - 30 + - 60 + type: integer + dailySnapshotRetentionDays: + description: Number of days to retain daily snapshots. Setting + 0 will disable this rule. + maximum: 365 + minimum: 0 + type: integer + fullIncrementalDayOfWeek: + description: Day of the week when Ops Manager takes a full + snapshot. This ensures a recent complete backup. Ops Manager + sets the default value to SUNDAY. + enum: + - SUNDAY + - MONDAY + - TUESDAY + - WEDNESDAY + - THURSDAY + - FRIDAY + - SATURDAY + type: string + monthlySnapshotRetentionMonths: + description: Number of months to retain weekly snapshots. + Setting 0 will disable this rule. + maximum: 36 + minimum: 0 + type: integer + pointInTimeWindowHours: + description: Number of hours in the past for which a point-in-time + snapshot can be created. + enum: + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 15 + - 30 + - 60 + - 90 + - 120 + - 180 + - 360 + type: integer + referenceHourOfDay: + description: Hour of the day to schedule snapshots using a + 24-hour clock, in UTC. + maximum: 23 + minimum: 0 + type: integer + referenceMinuteOfHour: + description: Minute of the hour to schedule snapshots, in + UTC. + maximum: 59 + minimum: 0 + type: integer + snapshotIntervalHours: + description: Number of hours between snapshots. + enum: + - 6 + - 8 + - 12 + - 24 + type: integer + snapshotRetentionDays: + description: Number of days to keep recent snapshots. + maximum: 365 + minimum: 1 + type: integer + weeklySnapshotRetentionWeeks: + description: Number of weeks to retain weekly snapshots. Setting + 0 will disable this rule + maximum: 365 + minimum: 0 + type: integer + type: object + type: object + cloudManager: + properties: + configMapRef: + properties: + name: + type: string + type: object + type: object + clusterDomain: + format: hostname + type: string + clusterSpecList: + items: + description: ClusterSpecItem is the mongodb multi-cluster spec that + is specific to a particular Kubernetes cluster, this maps to the + statefulset created in each cluster + properties: + clusterName: + description: ClusterName is name of the cluster where the MongoDB + Statefulset will be scheduled, the name should have a one + on one mapping with the service-account created in the central + cluster to talk to the workload clusters. + type: string + exposedExternally: + description: 'DEPRECATED: use ExternalAccessConfiguration instead' + type: boolean + externalAccess: + description: ExternalAccessConfiguration provides external access + configuration for Multi-Cluster. + properties: + externalDomain: + description: An external domain that is used for exposing + MongoDB to the outside world. + type: string + externalService: + description: Provides a way to override the default (NodePort) + Service + properties: + annotations: + additionalProperties: + type: string + description: A map of annotations that shall be added + to the externally available Service. + type: object + spec: + description: A wrapper for the Service spec object. + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + type: object + memberConfig: + description: MemberConfig + items: + properties: + priority: + type: string + tags: + additionalProperties: + type: string + type: object + votes: + type: integer + type: object + type: array + x-kubernetes-preserve-unknown-fields: true + members: + description: Amount of members for this MongoDB Replica Set + type: integer + service: + description: this is an optional service, it will get the name + "-service" in case not provided + type: string + statefulSet: + description: StatefulSetConfiguration holds the optional custom + StatefulSet that should be merged into the operator created + one. + properties: + spec: + type: object + x-kubernetes-preserve-unknown-fields: true + required: + - spec + type: object + required: + - members + type: object + type: array + connectivity: + properties: + replicaSetHorizons: + description: 'ReplicaSetHorizons holds list of maps of horizons + to be configured in each of MongoDB processes. Horizons map + horizon names to the node addresses for each process in the + replicaset, e.g.: [ { "internal": "my-rs-0.my-internal-domain.com:31843", + "external": "my-rs-0.my-external-domain.com:21467" }, { "internal": + "my-rs-1.my-internal-domain.com:31843", "external": "my-rs-1.my-external-domain.com:21467" + }, ... ] The key of each item in the map is an arbitrary, user-chosen + string that represents the name of the horizon. The value of + the item is the host and, optionally, the port that this mongod + node will be connected to from.' + items: + additionalProperties: + type: string + type: object + type: array + type: object + credentials: + description: Name of the Secret holding credentials information + type: string + duplicateServiceObjects: + description: 'In few service mesh options for ex: Istio, by default + we would need to duplicate the service objects created per pod in + all the clusters to enable DNS resolution. Users can however configure + their ServiceMesh with DNS proxy(https://istio.io/latest/docs/ops/configuration/traffic-management/dns-proxy/) + enabled in which case the operator doesn''t need to create the service + objects per cluster. This options tells the operator whether it + should create the service objects in all the clusters or not. By + default, if not specified the operator would create the duplicate + svc objects.' + type: boolean + exposedExternally: + description: 'DEPRECATED: use ExternalAccessConfiguration instead' + type: boolean + externalAccess: + description: ExternalAccessConfiguration provides external access + configuration. + properties: + externalDomain: + description: An external domain that is used for exposing MongoDB + to the outside world. + type: string + externalService: + description: Provides a way to override the default (NodePort) + Service + properties: + annotations: + additionalProperties: + type: string + description: A map of annotations that shall be added to the + externally available Service. + type: object + spec: + description: A wrapper for the Service spec object. + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + type: object + featureCompatibilityVersion: + type: string + logLevel: + enum: + - DEBUG + - INFO + - WARN + - ERROR + - FATAL + type: string + opsManager: + properties: + configMapRef: + properties: + name: + type: string + type: object + type: object + persistent: + type: boolean + prometheus: + description: Prometheus configurations. + properties: + metricsPath: + description: Indicates path to the metrics endpoint. + pattern: ^\/[a-z0-9]+$ + type: string + passwordSecretRef: + description: Name of a Secret containing a HTTP Basic Auth Password. + properties: + key: + description: Key is the key in the secret storing this password. + Defaults to "password" + type: string + name: + description: Name is the name of the secret storing this user's + password + type: string + required: + - name + type: object + port: + description: Port where metrics endpoint will bind to. Defaults + to 9216. + type: integer + tlsSecretKeyRef: + description: Name of a Secret (type kubernetes.io/tls) holding + the certificates to use in the Prometheus endpoint. + properties: + key: + description: Key is the key in the secret storing this password. + Defaults to "password" + type: string + name: + description: Name is the name of the secret storing this user's + password + type: string + required: + - name + type: object + username: + description: HTTP Basic Auth Username for metrics endpoint. + type: string + required: + - passwordSecretRef + - username + type: object + security: + properties: + authentication: + description: Authentication holds various authentication related + settings that affect this MongoDB resource. + properties: + agents: + description: Agents contains authentication configuration + properties for the agents + properties: + automationLdapGroupDN: + type: string + automationPasswordSecretRef: + description: SecretKeySelector selects a key of a Secret. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, + uid?' + type: string + optional: + description: Specify whether the Secret or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + automationUserName: + type: string + clientCertificateSecretRef: + type: object + x-kubernetes-preserve-unknown-fields: true + mode: + description: Mode is the desired Authentication mode that + the agents will use + type: string + required: + - mode + type: object + enabled: + type: boolean + ignoreUnknownUsers: + description: IgnoreUnknownUsers maps to the inverse of auth.authoritativeSet + type: boolean + internalCluster: + type: string + ldap: + description: LDAP Configuration + properties: + authzQueryTemplate: + type: string + bindQueryPasswordSecretRef: + properties: + name: + type: string + required: + - name + type: object + bindQueryUser: + type: string + caConfigMapRef: + description: Allows to point at a ConfigMap/key with a + CA file to mount on the Pod + properties: + key: + description: The key to select. + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, + uid?' + type: string + optional: + description: Specify whether the ConfigMap or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + servers: + items: + type: string + type: array + timeoutMS: + type: integer + transportSecurity: + enum: + - tls + - none + type: string + userCacheInvalidationInterval: + type: integer + userToDNMapping: + type: string + validateLDAPServerConfig: + type: boolean + type: object + modes: + items: + type: string + type: array + requireClientTLSAuthentication: + description: Clients should present valid TLS certificates + type: boolean + required: + - enabled + type: object + certsSecretPrefix: + type: string + roles: + items: + properties: + authenticationRestrictions: + items: + properties: + clientSource: + items: + type: string + type: array + serverAddress: + items: + type: string + type: array + type: object + type: array + db: + type: string + privileges: + items: + properties: + actions: + items: + type: string + type: array + resource: + properties: + cluster: + type: boolean + collection: + type: string + db: + type: string + type: object + required: + - actions + - resource + type: object + type: array + role: + type: string + roles: + items: + properties: + db: + type: string + role: + type: string + required: + - db + - role + type: object + type: array + required: + - db + - role + type: object + type: array + tls: + properties: + additionalCertificateDomains: + items: + type: string + type: array + ca: + description: CA corresponds to a ConfigMap containing an entry + for the CA certificate (ca.pem) used to validate the certificates + created already. + type: string + enabled: + description: DEPRECATED please enable TLS by setting `security.certsSecretPrefix` + or `security.tls.secretRef.prefix`. Enables TLS for this + resource. This will make the operator try to mount a Secret + with a defined name (-cert). This is only + used when enabling TLS on a MongoDB resource, and not on + the AppDB, where TLS is configured by setting `secretRef.Name`. + type: boolean + type: object + type: object + statefulSet: + description: StatefulSetConfiguration provides the statefulset override + for each of the cluster's statefulset if "StatefulSetConfiguration" + is specified at cluster level under "clusterSpecList" that takes + precedence over the global one + properties: + spec: + type: object + x-kubernetes-preserve-unknown-fields: true + required: + - spec + type: object + type: + enum: + - Standalone + - ReplicaSet + - ShardedCluster + type: string + version: + pattern: ^[0-9]+.[0-9]+.[0-9]+(-.+)?$|^$ + type: string + required: + - credentials + - type + - version + type: object + x-kubernetes-preserve-unknown-fields: true + status: + properties: + backup: + properties: + statusName: + type: string + required: + - statusName + type: object + clusterStatusList: + description: ClusterStatusList holds a list of clusterStatuses corresponding + to each cluster + properties: + clusterStatuses: + items: + description: ClusterStatusItem is the mongodb multi-cluster + spec that is specific to a particular Kubernetes cluster, + this maps to the statefulset created in each cluster + properties: + clusterName: + description: ClusterName is name of the cluster where the + MongoDB Statefulset will be scheduled, the name should + have a one on one mapping with the service-account created + in the central cluster to talk to the workload clusters. + type: string + lastTransition: + type: string + members: + type: integer + message: + type: string + observedGeneration: + format: int64 + type: integer + phase: + type: string + resourcesNotReady: + items: + description: ResourceNotReady describes the dependent + resource which is not ready yet + properties: + errors: + items: + properties: + message: + type: string + reason: + type: string + type: object + type: array + kind: + description: ResourceKind specifies a kind of a Kubernetes + resource. Used in status of a Custom Resource + type: string + message: + type: string + name: + type: string + required: + - kind + - name + type: object + type: array + warnings: + items: + type: string + type: array + required: + - phase + type: object + type: array + type: object + lastTransition: + type: string + link: + type: string + message: + type: string + observedGeneration: + format: int64 + type: integer + phase: + type: string + resourcesNotReady: + items: + description: ResourceNotReady describes the dependent resource which + is not ready yet + properties: + errors: + items: + properties: + message: + type: string + reason: + type: string + type: object + type: array + kind: + description: ResourceKind specifies a kind of a Kubernetes resource. + Used in status of a Custom Resource + type: string + message: + type: string + name: + type: string + required: + - kind + - name + type: object + type: array + version: + type: string + warnings: + items: + type: string + type: array + required: + - phase + - version + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/helm_chart/crds/mongodb.com_mongodbusers.yaml b/helm_chart/crds/mongodb.com_mongodbusers.yaml new file mode 100644 index 000000000..e3fcfb0fb --- /dev/null +++ b/helm_chart/crds/mongodb.com_mongodbusers.yaml @@ -0,0 +1,161 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.10.0 + creationTimestamp: null + name: mongodbusers.mongodb.com +spec: + group: mongodb.com + names: + kind: MongoDBUser + listKind: MongoDBUserList + plural: mongodbusers + shortNames: + - mdbu + singular: mongodbuser + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: The current state of the MongoDB User. + jsonPath: .status.phase + name: Phase + type: string + - description: The time since the MongoDB User resource was created. + jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + properties: + connectionStringSecretName: + type: string + db: + type: string + mongodbResourceRef: + properties: + name: + type: string + namespace: + type: string + required: + - name + type: object + passwordSecretKeyRef: + description: 'SecretKeyRef is a reference to a value in a given secret + in the same namespace. Based on: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.15/#secretkeyselector-v1-core' + properties: + key: + type: string + name: + type: string + required: + - name + type: object + roles: + items: + properties: + db: + type: string + name: + type: string + required: + - db + - name + type: object + type: array + username: + type: string + required: + - db + - username + type: object + status: + properties: + db: + type: string + lastTransition: + type: string + message: + type: string + observedGeneration: + format: int64 + type: integer + phase: + type: string + project: + type: string + resourcesNotReady: + items: + description: ResourceNotReady describes the dependent resource which + is not ready yet + properties: + errors: + items: + properties: + message: + type: string + reason: + type: string + type: object + type: array + kind: + description: ResourceKind specifies a kind of a Kubernetes resource. + Used in status of a Custom Resource + type: string + message: + type: string + name: + type: string + required: + - kind + - name + type: object + type: array + roles: + items: + properties: + db: + type: string + name: + type: string + required: + - db + - name + type: object + type: array + username: + type: string + warnings: + items: + type: string + type: array + required: + - db + - phase + - project + - username + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/helm_chart/crds/mongodb.com_opsmanagers.yaml b/helm_chart/crds/mongodb.com_opsmanagers.yaml new file mode 100644 index 000000000..74dbdc510 --- /dev/null +++ b/helm_chart/crds/mongodb.com_opsmanagers.yaml @@ -0,0 +1,1075 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.10.0 + creationTimestamp: null + name: opsmanagers.mongodb.com +spec: + group: mongodb.com + names: + kind: MongoDBOpsManager + listKind: MongoDBOpsManagerList + plural: opsmanagers + shortNames: + - om + singular: opsmanager + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: The number of replicas of MongoDBOpsManager. + jsonPath: .spec.replicas + name: Replicas + type: integer + - description: The version of MongoDBOpsManager. + jsonPath: .spec.version + name: Version + type: string + - description: The current state of the MongoDBOpsManager. + jsonPath: .status.opsManager.phase + name: State (OpsManager) + type: string + - description: The current state of the MongoDBOpsManager Application Database. + jsonPath: .status.applicationDatabase.phase + name: State (AppDB) + type: string + - description: The current state of the MongoDBOpsManager Backup Daemon. + jsonPath: .status.backup.phase + name: State (Backup) + type: string + - description: The time since the MongoDBOpsManager resource was created. + jsonPath: .metadata.creationTimestamp + name: Age + type: date + - description: Warnings. + jsonPath: .status.warnings + name: Warnings + type: string + name: v1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + properties: + adminCredentials: + description: 'AdminSecret is the secret for the first admin user to + create has the fields: "Username", "Password", "FirstName", "LastName"' + type: string + applicationDatabase: + properties: + additionalMongodConfig: + description: 'AdditionalMongodConfig is additional configuration + that can be passed to each data-bearing mongod at runtime. Uses + the same structure as the mongod configuration file: https://docs.mongodb.com/manual/reference/configuration-options/' + type: object + x-kubernetes-preserve-unknown-fields: true + agent: + description: specify startup flags for the AutomationAgent and + MonitoringAgent + properties: + logLevel: + type: string + maxLogFileDurationHours: + type: integer + startupOptions: + additionalProperties: + type: string + type: object + type: object + automationConfig: + description: AutomationConfigOverride holds any fields that will + be merged on top of the Automation Config that the operator + creates for the AppDB. Currently only the process.disabled field + is recognized. + properties: + processes: + items: + description: OverrideProcess contains fields that we can + override on the AutomationConfig processes. + properties: + disabled: + type: boolean + name: + type: string + required: + - disabled + - name + type: object + type: array + required: + - processes + type: object + cloudManager: + properties: + configMapRef: + properties: + name: + type: string + type: object + type: object + clusterDomain: + type: string + connectivity: + properties: + replicaSetHorizons: + description: 'ReplicaSetHorizons holds list of maps of horizons + to be configured in each of MongoDB processes. Horizons + map horizon names to the node addresses for each process + in the replicaset, e.g.: [ { "internal": "my-rs-0.my-internal-domain.com:31843", + "external": "my-rs-0.my-external-domain.com:21467" }, { + "internal": "my-rs-1.my-internal-domain.com:31843", "external": + "my-rs-1.my-external-domain.com:21467" }, ... ] The key + of each item in the map is an arbitrary, user-chosen string + that represents the name of the horizon. The value of the + item is the host and, optionally, the port that this mongod + node will be connected to from.' + items: + additionalProperties: + type: string + type: object + type: array + type: object + credentials: + description: Name of the Secret holding credentials information + type: string + featureCompatibilityVersion: + type: string + logLevel: + enum: + - DEBUG + - INFO + - WARN + - ERROR + - FATAL + type: string + memberConfig: + description: MemberConfig + items: + properties: + priority: + type: string + tags: + additionalProperties: + type: string + type: object + votes: + type: integer + type: object + type: array + members: + description: Amount of members for this MongoDB Replica Set + maximum: 50 + minimum: 3 + type: integer + monitoringAgent: + description: specify startup flags for just the MonitoringAgent. + These take precedence over the flags set in AutomationAgent + properties: + logLevel: + type: string + maxLogFileDurationHours: + type: integer + startupOptions: + additionalProperties: + type: string + type: object + type: object + opsManager: + properties: + configMapRef: + properties: + name: + type: string + type: object + type: object + passwordSecretKeyRef: + description: PasswordSecretKeyRef contains a reference to the + secret which contains the password for the mongodb-ops-manager + SCRAM-SHA user + properties: + key: + type: string + name: + type: string + required: + - name + type: object + podSpec: + properties: + persistence: + properties: + multiple: + properties: + data: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + journal: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + logs: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + single: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + podTemplate: + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + prometheus: + description: Enables Prometheus integration on the AppDB. + properties: + metricsPath: + description: Indicates path to the metrics endpoint. + pattern: ^\/[a-z0-9]+$ + type: string + passwordSecretRef: + description: Name of a Secret containing a HTTP Basic Auth + Password. + properties: + key: + description: Key is the key in the secret storing this + password. Defaults to "password" + type: string + name: + description: Name is the name of the secret storing this + user's password + type: string + required: + - name + type: object + port: + description: Port where metrics endpoint will bind to. Defaults + to 9216. + type: integer + tlsSecretKeyRef: + description: Name of a Secret (type kubernetes.io/tls) holding + the certificates to use in the Prometheus endpoint. + properties: + key: + description: Key is the key in the secret storing this + password. Defaults to "password" + type: string + name: + description: Name is the name of the secret storing this + user's password + type: string + required: + - name + type: object + username: + description: HTTP Basic Auth Username for metrics endpoint. + type: string + required: + - passwordSecretRef + - username + type: object + security: + properties: + authentication: + description: Authentication holds various authentication related + settings that affect this MongoDB resource. + properties: + agents: + description: Agents contains authentication configuration + properties for the agents + properties: + automationLdapGroupDN: + type: string + automationPasswordSecretRef: + description: SecretKeySelector selects a key of a + Secret. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + description: 'Name of the referent. More info: + https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, + uid?' + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + automationUserName: + type: string + clientCertificateSecretRef: + type: object + x-kubernetes-preserve-unknown-fields: true + mode: + description: Mode is the desired Authentication mode + that the agents will use + type: string + required: + - mode + type: object + enabled: + type: boolean + ignoreUnknownUsers: + description: IgnoreUnknownUsers maps to the inverse of + auth.authoritativeSet + type: boolean + internalCluster: + type: string + ldap: + description: LDAP Configuration + properties: + authzQueryTemplate: + type: string + bindQueryPasswordSecretRef: + properties: + name: + type: string + required: + - name + type: object + bindQueryUser: + type: string + caConfigMapRef: + description: Allows to point at a ConfigMap/key with + a CA file to mount on the Pod + properties: + key: + description: The key to select. + type: string + name: + description: 'Name of the referent. More info: + https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, + uid?' + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + servers: + items: + type: string + type: array + timeoutMS: + type: integer + transportSecurity: + enum: + - tls + - none + type: string + userCacheInvalidationInterval: + type: integer + userToDNMapping: + type: string + validateLDAPServerConfig: + type: boolean + type: object + modes: + items: + type: string + type: array + requireClientTLSAuthentication: + description: Clients should present valid TLS certificates + type: boolean + required: + - enabled + type: object + certsSecretPrefix: + type: string + roles: + items: + properties: + authenticationRestrictions: + items: + properties: + clientSource: + items: + type: string + type: array + serverAddress: + items: + type: string + type: array + type: object + type: array + db: + type: string + privileges: + items: + properties: + actions: + items: + type: string + type: array + resource: + properties: + cluster: + type: boolean + collection: + type: string + db: + type: string + type: object + required: + - actions + - resource + type: object + type: array + role: + type: string + roles: + items: + properties: + db: + type: string + role: + type: string + required: + - db + - role + type: object + type: array + required: + - db + - role + type: object + type: array + tls: + properties: + additionalCertificateDomains: + items: + type: string + type: array + ca: + description: CA corresponds to a ConfigMap containing + an entry for the CA certificate (ca.pem) used to validate + the certificates created already. + type: string + enabled: + description: DEPRECATED please enable TLS by setting `security.certsSecretPrefix` + or `security.tls.secretRef.prefix`. Enables TLS for + this resource. This will make the operator try to mount + a Secret with a defined name (-cert). + This is only used when enabling TLS on a MongoDB resource, + and not on the AppDB, where TLS is configured by setting + `secretRef.Name`. + type: boolean + type: object + type: object + service: + description: this is an optional service, it will get the name + "-service" in case not provided + type: string + type: + enum: + - Standalone + - ReplicaSet + - ShardedCluster + type: string + version: + pattern: ^[0-9]+.[0-9]+.[0-9]+(-.+)?$|^$ + type: string + required: + - version + type: object + backup: + description: Backup + properties: + assignmentLabels: + description: Assignment Labels set in the Ops Manager + items: + type: string + type: array + blockStores: + items: + description: DataStoreConfig is the description of the config + used to reference to database. Reused by Oplog and Block stores + Optionally references the user if the Mongodb is configured + with authentication + properties: + assignmentLabels: + description: Assignment Labels set in the Ops Manager + items: + type: string + type: array + mongodbResourceRef: + properties: + name: + type: string + namespace: + type: string + required: + - name + type: object + mongodbUserRef: + properties: + name: + type: string + required: + - name + type: object + name: + type: string + required: + - mongodbResourceRef + - name + type: object + type: array + enabled: + description: Enabled indicates if Backups will be enabled for + this Ops Manager. + type: boolean + encryption: + description: Encryption settings + properties: + kmip: + description: Kmip corresponds to the KMIP configuration assigned + to the Ops Manager Project's configuration. + properties: + server: + description: KMIP Server configuration + properties: + ca: + description: CA corresponds to a ConfigMap containing + an entry for the CA certificate (ca.pem) used for + KMIP authentication + type: string + url: + description: 'KMIP Server url in the following format: + hostname:port Valid examples are: 10.10.10.3:5696 + my-kmip-server.mycorp.com:5696 kmip-svc.svc.cluster.local:5696' + pattern: '[^\:]+:[0-9]{0,5}' + type: string + required: + - ca + - url + type: object + required: + - server + type: object + type: object + externalServiceEnabled: + type: boolean + fileSystemStores: + items: + properties: + name: + type: string + required: + - name + type: object + type: array + headDB: + description: HeadDB specifies configuration options for the HeadDB + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + jvmParameters: + items: + type: string + type: array + members: + description: Members indicate the number of backup daemon pods + to create. + minimum: 1 + type: integer + opLogStores: + description: OplogStoreConfigs describes the list of oplog store + configs used for backup + items: + description: DataStoreConfig is the description of the config + used to reference to database. Reused by Oplog and Block stores + Optionally references the user if the Mongodb is configured + with authentication + properties: + assignmentLabels: + description: Assignment Labels set in the Ops Manager + items: + type: string + type: array + mongodbResourceRef: + properties: + name: + type: string + namespace: + type: string + required: + - name + type: object + mongodbUserRef: + properties: + name: + type: string + required: + - name + type: object + name: + type: string + required: + - mongodbResourceRef + - name + type: object + type: array + queryableBackupSecretRef: + description: QueryableBackupSecretRef references the secret which + contains the pem file which is used for queryable backup. This + will be mounted into the Ops Manager pod. + properties: + name: + type: string + required: + - name + type: object + s3OpLogStores: + description: S3OplogStoreConfigs describes the list of s3 oplog + store configs used for backup. + items: + properties: + assignmentLabels: + description: Assignment Labels set in the Ops Manager + items: + type: string + type: array + customCertificate: + description: Set this to "true" when you have custom certificates + for your S3 buckets + type: boolean + irsaEnabled: + description: 'This is only set to "true" when user is running + in EKS and is using AWS IRSA to configure S3 snapshot + store. For more details refer this: https://aws.amazon.com/blogs/opensource/introducing-fine-grained-iam-roles-service-accounts/' + type: boolean + mongodbResourceRef: + properties: + name: + type: string + namespace: + type: string + required: + - name + type: object + mongodbUserRef: + properties: + name: + type: string + required: + - name + type: object + name: + type: string + pathStyleAccessEnabled: + type: boolean + s3BucketEndpoint: + type: string + s3BucketName: + type: string + s3RegionOverride: + type: string + s3SecretRef: + properties: + name: + type: string + required: + - name + type: object + required: + - name + - pathStyleAccessEnabled + - s3BucketEndpoint + - s3BucketName + - s3SecretRef + type: object + type: array + s3Stores: + items: + properties: + assignmentLabels: + description: Assignment Labels set in the Ops Manager + items: + type: string + type: array + customCertificate: + description: Set this to "true" when you have custom certificates + for your S3 buckets + type: boolean + irsaEnabled: + description: 'This is only set to "true" when user is running + in EKS and is using AWS IRSA to configure S3 snapshot + store. For more details refer this: https://aws.amazon.com/blogs/opensource/introducing-fine-grained-iam-roles-service-accounts/' + type: boolean + mongodbResourceRef: + properties: + name: + type: string + namespace: + type: string + required: + - name + type: object + mongodbUserRef: + properties: + name: + type: string + required: + - name + type: object + name: + type: string + pathStyleAccessEnabled: + type: boolean + s3BucketEndpoint: + type: string + s3BucketName: + type: string + s3RegionOverride: + type: string + s3SecretRef: + properties: + name: + type: string + required: + - name + type: object + required: + - name + - pathStyleAccessEnabled + - s3BucketEndpoint + - s3BucketName + - s3SecretRef + type: object + type: array + statefulSet: + description: StatefulSetConfiguration holds the optional custom + StatefulSet that should be merged into the operator created + one. + properties: + spec: + type: object + x-kubernetes-preserve-unknown-fields: true + required: + - spec + type: object + required: + - enabled + type: object + clusterDomain: + format: hostname + type: string + clusterName: + description: 'Deprecated: This has been replaced by the ClusterDomain + which should be used instead' + format: hostname + type: string + configuration: + additionalProperties: + type: string + description: The configuration properties passed to Ops Manager/Backup + Daemon + type: object + externalConnectivity: + description: MongoDBOpsManagerExternalConnectivity if sets allows + for the creation of a Service for accessing this Ops Manager resource + from outside the Kubernetes cluster. + properties: + annotations: + additionalProperties: + type: string + description: Annotations is a list of annotations to be directly + passed to the Service object. + type: object + externalTrafficPolicy: + description: ExternalTrafficPolicy mechanism to preserve the client + source IP. Only supported on GCE and Google Kubernetes Engine. + enum: + - Cluster + - Local + type: string + loadBalancerIP: + description: LoadBalancerIP IP that will be assigned to this LoadBalancer. + type: string + port: + description: Port in which this `Service` will listen to, this + applies to `NodePort`. + format: int32 + type: integer + type: + description: Type of the `Service` to be created. + enum: + - LoadBalancer + - NodePort + type: string + required: + - type + type: object + jvmParameters: + description: Custom JVM parameters passed to the Ops Manager JVM + items: + type: string + type: array + replicas: + minimum: 1 + type: integer + security: + description: Configure HTTPS. + properties: + certsSecretPrefix: + type: string + tls: + properties: + ca: + type: string + secretRef: + properties: + name: + type: string + required: + - name + type: object + type: object + type: object + statefulSet: + description: Configure custom StatefulSet configuration + properties: + spec: + type: object + x-kubernetes-preserve-unknown-fields: true + required: + - spec + type: object + version: + type: string + required: + - applicationDatabase + - version + type: object + status: + properties: + applicationDatabase: + properties: + backup: + properties: + statusName: + type: string + required: + - statusName + type: object + configServerCount: + type: integer + lastTransition: + type: string + link: + type: string + members: + type: integer + message: + type: string + mongodsPerShardCount: + type: integer + mongosCount: + type: integer + observedGeneration: + format: int64 + type: integer + phase: + type: string + resourcesNotReady: + items: + description: ResourceNotReady describes the dependent resource + which is not ready yet + properties: + errors: + items: + properties: + message: + type: string + reason: + type: string + type: object + type: array + kind: + description: ResourceKind specifies a kind of a Kubernetes + resource. Used in status of a Custom Resource + type: string + message: + type: string + name: + type: string + required: + - kind + - name + type: object + type: array + shardCount: + type: integer + version: + type: string + warnings: + items: + type: string + type: array + required: + - phase + - version + type: object + backup: + properties: + lastTransition: + type: string + message: + type: string + observedGeneration: + format: int64 + type: integer + phase: + type: string + resourcesNotReady: + items: + description: ResourceNotReady describes the dependent resource + which is not ready yet + properties: + errors: + items: + properties: + message: + type: string + reason: + type: string + type: object + type: array + kind: + description: ResourceKind specifies a kind of a Kubernetes + resource. Used in status of a Custom Resource + type: string + message: + type: string + name: + type: string + required: + - kind + - name + type: object + type: array + version: + type: string + warnings: + items: + type: string + type: array + required: + - phase + type: object + opsManager: + properties: + lastTransition: + type: string + message: + type: string + observedGeneration: + format: int64 + type: integer + phase: + type: string + replicas: + type: integer + resourcesNotReady: + items: + description: ResourceNotReady describes the dependent resource + which is not ready yet + properties: + errors: + items: + properties: + message: + type: string + reason: + type: string + type: object + type: array + kind: + description: ResourceKind specifies a kind of a Kubernetes + resource. Used in status of a Custom Resource + type: string + message: + type: string + name: + type: string + required: + - kind + - name + type: object + type: array + url: + type: string + version: + type: string + warnings: + items: + type: string + type: array + required: + - phase + type: object + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/helm_chart/templates/_helpers.tpl b/helm_chart/templates/_helpers.tpl new file mode 100644 index 000000000..816436a12 --- /dev/null +++ b/helm_chart/templates/_helpers.tpl @@ -0,0 +1,13 @@ +{{/* vim: set filetype=mustache: */}} + +{{/* + Define the namespace in which the operator deployment will be running. + This can be used to override the installation of the operator in the same namespace as the helm release +*/}} +{{- define "mongodb-enterprise-operator.namespace" -}} +{{- if .Values.operator.namespace -}} +{{- .Values.operator.namespace -}} +{{- else -}} +{{- .Release.Namespace -}} +{{- end -}} +{{- end -}} diff --git a/helm_chart/templates/database-roles.yaml b/helm_chart/templates/database-roles.yaml new file mode 100644 index 000000000..e89927b7a --- /dev/null +++ b/helm_chart/templates/database-roles.yaml @@ -0,0 +1,83 @@ +{{- $watchNamespace := include "mongodb-enterprise-operator.namespace" . | list }} +{{- if .Values.operator.watchNamespace }} +{{- $watchNamespace = regexSplit "," .Values.operator.watchNamespace -1 }} +{{- end }} + + +{{- range $idx, $namespace := $watchNamespace }} + +{{- $namespaceBlock := printf "namespace: %s" $namespace }} +{{- if eq $namespace "*" }} +{{- $namespaceBlock = include "mongodb-enterprise-operator.namespace" $ | printf "namespace: %s" }} +{{- end }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: mongodb-enterprise-appdb + {{ $namespaceBlock }} +{{- if $.Values.registry.imagePullSecrets}} +imagePullSecrets: + - name: {{ $.Values.registry.imagePullSecrets }} +{{- end }} + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: mongodb-enterprise-database-pods + {{ $namespaceBlock }} +{{- if $.Values.registry.imagePullSecrets}} +imagePullSecrets: + - name: {{ $.Values.registry.imagePullSecrets }} +{{- end }} + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: mongodb-enterprise-ops-manager + {{ $namespaceBlock }} +{{- if $.Values.registry.imagePullSecrets}} +imagePullSecrets: + - name: {{ $.Values.registry.imagePullSecrets }} +{{- end }} + +--- +kind: Role +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: mongodb-enterprise-appdb + {{ $namespaceBlock }} +rules: + - apiGroups: + - '' + resources: + - secrets + verbs: + - get + - apiGroups: + - '' + resources: + - pods + verbs: + - patch + - delete + - get + +--- +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: mongodb-enterprise-appdb + {{ $namespaceBlock }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: mongodb-enterprise-appdb +subjects: + - kind: ServiceAccount + name: mongodb-enterprise-appdb + {{ $namespaceBlock }} + +{{- end }} diff --git a/helm_chart/templates/operator-roles.yaml b/helm_chart/templates/operator-roles.yaml new file mode 100644 index 000000000..6af13b6fb --- /dev/null +++ b/helm_chart/templates/operator-roles.yaml @@ -0,0 +1,178 @@ +{{ if .Values.operator.createOperatorServiceAccount }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Values.operator.name }} + namespace: {{ include "mongodb-enterprise-operator.namespace" . }} +{{- if .Values.registry.imagePullSecrets}} +imagePullSecrets: + - name: {{ .Values.registry.imagePullSecrets }} +{{- end }} + +{{- $watchNamespace := include "mongodb-enterprise-operator.namespace" . | list }} +{{- if .Values.operator.watchNamespace }} +{{- $watchNamespace = regexSplit "," .Values.operator.watchNamespace -1 }} +{{- $watchNamespace = concat $watchNamespace (include "mongodb-enterprise-operator.namespace" . | list) | uniq }} +{{- end }} + +{{- $roleScope := "Role" -}} +{{- if or (gt (len $watchNamespace) 1) (eq (first $watchNamespace) "*") }} +{{- $roleScope = "ClusterRole" }} +{{- end }} + +--- +kind: {{ $roleScope }} +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: {{ .Values.operator.name }} +{{- if eq $roleScope "Role" }} + namespace: {{ include "mongodb-enterprise-operator.namespace" . }} +{{- end }} +rules: + - apiGroups: + - '' + resources: + - services + verbs: + - get + - list + - watch + - create + - update + - delete + - apiGroups: + - '' + resources: + - secrets + - configmaps + verbs: + - get + - list + - create + - update + - delete + - watch + - apiGroups: + - apps + resources: + - statefulsets + verbs: + - create + - get + - list + - watch + - delete + - update + - apiGroups: + - '' + resources: + - pods + verbs: + - get + - list + - watch + - delete + - deletecollection + - apiGroups: + - mongodb.com + verbs: + - '*' + resources: + - mongodb + - mongodb/finalizers + - mongodbusers + - opsmanagers + - opsmanagers/finalizers + - mongodbmulticluster + - mongodbmulticluster/finalizers + {{- if .Values.subresourceEnabled }} + - mongodb/status + - mongodbusers/status + - opsmanagers/status + - mongodbmulticluster/status + {{- end }} +{{- if eq $roleScope "ClusterRole" }} + - apiGroups: + - '' + resources: + - namespaces + verbs: + - list + - watch +{{- end}} + +{{- range $idx, $namespace := $watchNamespace }} + +{{- $namespaceBlock := "" }} +{{- if not (eq $namespace "*") }} +{{- $namespaceBlock = printf "namespace: %s" $namespace }} +{{- end }} + +--- +{{- if eq $namespace "*" }} +kind: ClusterRoleBinding +{{- else }} +kind: RoleBinding +{{- end }} +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: {{ $.Values.operator.name }} + {{ $namespaceBlock }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: {{ $roleScope }} + name: {{ $.Values.operator.name }} +subjects: + - kind: ServiceAccount + name: {{ $.Values.operator.name }} + namespace: {{ include "mongodb-enterprise-operator.namespace" $ }} +{{- end }} + +{{- end }} + +# This ClusterRoleBinding is necessary in order to use validating +# webhooks—these will prevent you from applying a variety of invalid resource +# definitions. The validating webhooks are optional so this can be removed if +# necessary. +--- +{{- if not (lookup "rbac.authorization.k8s.io/v1" "ClusterRole" "" "mongodb-enterprise-operator-mongodb-webhook") }} +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: mongodb-enterprise-operator-mongodb-webhook +rules: + - apiGroups: + - "admissionregistration.k8s.io" + resources: + - validatingwebhookconfigurations + verbs: + - get + - create + - update + - delete + - apiGroups: + - "" + resources: + - services + verbs: + - get + - list + - watch + - create + - update + - delete +{{- end }} +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: {{ .Values.operator.name }}-{{ include "mongodb-enterprise-operator.namespace" . }}-webhook-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: mongodb-enterprise-operator-mongodb-webhook +subjects: + - kind: ServiceAccount + name: {{ .Values.operator.name }} + namespace: {{ include "mongodb-enterprise-operator.namespace" . }} diff --git a/helm_chart/templates/operator.yaml b/helm_chart/templates/operator.yaml new file mode 100644 index 000000000..54be516c6 --- /dev/null +++ b/helm_chart/templates/operator.yaml @@ -0,0 +1,245 @@ +{{ $ns := include "mongodb-enterprise-operator.namespace" . -}} + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.operator.name }} + namespace: {{$ns}} +spec: + replicas: {{ .Values.operator.replicas }} + selector: + matchLabels: + app.kubernetes.io/component: controller + app.kubernetes.io/name: {{ .Values.operator.name }} + app.kubernetes.io/instance: {{ .Values.operator.name }} + template: + metadata: + labels: + app.kubernetes.io/component: controller + app.kubernetes.io/name: {{ .Values.operator.name }} + app.kubernetes.io/instance: {{ .Values.operator.name }} +{{- if .Values.operator.vaultSecretBackend }} + {{- if .Values.operator.vaultSecretBackend.enabled }} + annotations: + vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/role: "mongodbenterprise" + {{- if .Values.operator.vaultSecretBackend.tlsSecretRef }} + vault.hashicorp.com/tls-secret: {{ .Values.operator.vaultSecretBackend.tlsSecretRef }} + vault.hashicorp.com/ca-cert: /vault/tls/ca.crt + {{- end }} + {{- end }} +{{- end }} + spec: + serviceAccountName: {{ .Values.operator.name }} +{{- if not .Values.managedSecurityContext }} + securityContext: + runAsNonRoot: true + runAsUser: 2000 +{{- end }} +{{- if .Values.registry.imagePullSecrets}} + imagePullSecrets: + - name: {{ .Values.registry.imagePullSecrets }} +{{- end }} + containers: + - name: {{ .Values.operator.name }} + image: "{{ .Values.registry.operator }}/{{ .Values.operator.operator_image_name }}:{{ .Values.operator.version }}{{ .Values.build }}" + imagePullPolicy: {{ .Values.registry.pullPolicy }} + {{- if .Values.operator.watchedResources }} + args: + {{- range .Values.operator.watchedResources }} + - -watch-resource={{ . }} + {{- end }} + {{- if .Values.multiCluster.clusters }} + - -watch-resource=mongodbmulticluster + {{- end }} + command: + - /usr/local/bin/mongodb-enterprise-operator + {{- end }} + {{- if .Values.multiCluster.clusters }} + volumeMounts: + - mountPath: /etc/config/kubeconfig + name: kube-config-volume + {{- end }} + resources: + limits: + cpu: {{ .Values.operator.resources.limits.cpu }} + memory: {{ .Values.operator.resources.limits.memory }} + requests: + cpu: {{ .Values.operator.resources.requests.cpu }} + memory: {{ .Values.operator.resources.requests.memory }} + env: + - name: OPERATOR_ENV + value: {{ .Values.operator.env }} + {{- if .Values.operator.vaultSecretBackend }} + {{- if .Values.operator.vaultSecretBackend.enabled }} + - name: SECRET_BACKEND + value: VAULT_BACKEND + {{- end }} + {{- end }} + - name: WATCH_NAMESPACE + {{- if .Values.operator.watchNamespace }} + value: "{{ .Values.operator.watchNamespace }}" + {{- else }} + valueFrom: + fieldRef: + fieldPath: metadata.namespace + {{- end }} + - name: CURRENT_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + {{- if eq .Values.managedSecurityContext true }} + - name: MANAGED_SECURITY_CONTEXT + value: 'true' + {{- end }} + {{- if .Values.multiCluster.clusterClientTimeout }} + - name: CLUSTER_CLIENT_TIMEOUT + value: "{{ .Values.multiCluster.clusterClientTimeout }}" + {{- end }} + {{- $mongodbEnterpriseDatabaseImageEnv := "MONGODB_ENTERPRISE_DATABASE_IMAGE" -}} + {{- $initDatabaseImageRepositoryEnv := "INIT_DATABASE_IMAGE_REPOSITORY" -}} + {{- $opsManagerImageRepositoryEnv := "OPS_MANAGER_IMAGE_REPOSITORY" -}} + {{- $initOpsManagerImageRepositoryEnv := "INIT_OPS_MANAGER_IMAGE_REPOSITORY" -}} + {{- $initAppDbImageRepositoryEnv := "INIT_APPDB_IMAGE_REPOSITORY" -}} + {{- $agentImageEnv := "AGENT_IMAGE" -}} + {{- $mongodbImageEnv := "MONGODB_IMAGE" -}} + {{- $initDatabaseVersion := print .Values.initDatabase.version (.Values.build | default "") -}} + {{- $databaseVersion := print .Values.database.version (.Values.build | default "") -}} + {{- $initOpsManagerVersion := print .Values.initOpsManager.version (.Values.build | default "") -}} + {{- $initAppDbVersion := print .Values.initAppDb.version (.Values.build | default "") -}} + {{- $agentVersion := .Values.agent.version }} + - name: IMAGE_PULL_POLICY + value: {{ .Values.registry.pullPolicy }} + # Database + - name: {{ $mongodbEnterpriseDatabaseImageEnv }} + value: {{ .Values.registry.database }}/{{ .Values.database.name }} + - name: {{ $initDatabaseImageRepositoryEnv }} + value: {{ .Values.registry.initDatabase }}/{{ .Values.initDatabase.name }} + - name: INIT_DATABASE_VERSION + value: {{ $initDatabaseVersion }} + - name: DATABASE_VERSION + value: {{ $databaseVersion }} + # Ops Manager + - name: {{ $opsManagerImageRepositoryEnv }} + value: {{ .Values.registry.opsManager }}/{{ .Values.opsManager.name }} + - name: {{ $initOpsManagerImageRepositoryEnv }} + value: {{ .Values.registry.initOpsManager }}/{{ .Values.initOpsManager.name }} + - name: INIT_OPS_MANAGER_VERSION + value: {{ $initOpsManagerVersion }} + # AppDB + - name: {{ $initAppDbImageRepositoryEnv }} + value: {{ .Values.registry.initAppDb }}/{{ .Values.initAppDb.name }} + - name: INIT_APPDB_VERSION + value: {{ $initAppDbVersion }} + - name: OPS_MANAGER_IMAGE_PULL_POLICY + value: {{ .Values.registry.pullPolicy }} + - name: {{ $agentImageEnv }} + value: "{{ .Values.registry.agent }}/{{ .Values.agent.name }}:{{ $agentVersion }}" + - name: {{ $mongodbImageEnv }} + value: {{ .Values.mongodb.name }} + - name: MONGODB_REPO_URL + value: {{ .Values.mongodb.repo }} + - name: MDB_IMAGE_TYPE + value: {{ .Values.mongodb.imageType }} + {{- if eq .Values.mongodb.appdbAssumeOldFormat true }} + - name: MDB_APPDB_ASSUME_OLD_FORMAT + value: 'true' + {{- end }} + {{- if eq .Values.multiCluster.performFailOver true }} + - name: PERFORM_FAILOVER + value: 'true' + {{- end }} + {{- if .Values.registry.imagePullSecrets }} + - name: IMAGE_PULL_SECRETS + value: {{ .Values.registry.imagePullSecrets }} + {{- end }} + {{- if .Values.relatedImages }} + - name: RELATED_IMAGE_{{ $mongodbEnterpriseDatabaseImageEnv }}_{{ $databaseVersion | replace "." "_" | replace "-" "_" }} + value: "{{ .Values.registry.database }}/{{ .Values.database.name }}:{{ $databaseVersion }}" + - name: RELATED_IMAGE_{{ $initDatabaseImageRepositoryEnv }}_{{ $initDatabaseVersion | replace "." "_" | replace "-" "_" }} + value: "{{ .Values.registry.initDatabase }}/{{ .Values.initDatabase.name }}:{{ $initDatabaseVersion }}" + - name: RELATED_IMAGE_{{ $initOpsManagerImageRepositoryEnv }}_{{ $initOpsManagerVersion | replace "." "_" | replace "-" "_" }} + value: "{{ .Values.registry.initOpsManager }}/{{ .Values.initOpsManager.name }}:{{ $initOpsManagerVersion }}" + - name: RELATED_IMAGE_{{ $initAppDbImageRepositoryEnv }}_{{ $initAppDbVersion | replace "." "_" | replace "-" "_" }} + value: "{{ .Values.registry.initAppDb }}/{{ .Values.initAppDb.name }}:{{ $initAppDbVersion }}" + {{- range $version := .Values.relatedImages.agent }} + - name: RELATED_IMAGE_{{ $agentImageEnv }}_{{ $version | replace "." "_" | replace "-" "_" }} + value: "{{ $.Values.registry.agent }}/{{ $.Values.agent.name }}:{{ $version }}" + {{- end }} + {{- range $version := .Values.relatedImages.opsManager }} + - name: RELATED_IMAGE_{{ $opsManagerImageRepositoryEnv }}_{{ $version | replace "." "_" | replace "-" "_" }} + value: "{{ $.Values.registry.opsManager }}/{{ $.Values.opsManager.name }}:{{ $version }}" + {{- end }} + # since the official server images end with a different suffix we can re-use the same $mongodbImageEnv + {{- range $version := .Values.relatedImages.mongodb }} + - name: RELATED_IMAGE_{{ $mongodbImageEnv }}_{{ $version | replace "." "_" | replace "-" "_" }} + value: "{{ $.Values.mongodb.repo }}/{{ $.Values.mongodb.name }}:{{ $version }}" + {{- end }} + # mongodbLegacyAppDb will be deleted in 1.23 release + {{- range $version := .Values.relatedImages.mongodbLegacyAppDb }} + - name: RELATED_IMAGE_{{ $mongodbImageEnv }}_{{ $version | replace "." "_" | replace "-" "_" }} + value: "{{ $.Values.mongodbLegacyAppDb.repo }}/{{ $.Values.mongodbLegacyAppDb.name }}:{{ $version }}" + {{- end }} + {{- end }} + {{- if .Values.customEnvVars }} + {{- range split "&" .Values.customEnvVars }} + - name: {{ (split "=" .)._0 }} + value: '{{ (split "=" .)._1 }}' + {{- end }} + {{- end }} +{{- if .Values.multiCluster.clusters }} + volumes: + - name: kube-config-volume + secret: + defaultMode: 420 + secretName: {{ .Values.multiCluster.kubeConfigSecretName }} +{{- end }} + +{{- with .Values.operator }} + {{- with .nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} + +{{- if .Values.debug }} +--- +apiVersion: v1 +kind: Service +metadata: + name: debug-svc +spec: + type: NodePort + ports: + - nodePort: {{ .Values.debugPort }} + port: 40000 + protocol: TCP + selector: + app.kubernetes.io/name: {{ .Values.operator.name }} +{{- end }} + +{{- if not (lookup "v1" "ConfigMap" $ns "mongodb-enterprise-operator-member-list") }} +{{- if .Values.multiCluster.clusters }} +--- +apiVersion: v1 +kind: ConfigMap +data: + {{- range .Values.multiCluster.clusters }} + {{ . | indent 1 }}: "" + {{- end }} +metadata: + namespace: {{$ns}} + name: mongodb-enterprise-operator-member-list + labels: + multi-cluster: "true" +{{- end }} +{{- end }} diff --git a/helm_chart/templates/secret-config.yaml b/helm_chart/templates/secret-config.yaml new file mode 100644 index 000000000..fa6433832 --- /dev/null +++ b/helm_chart/templates/secret-config.yaml @@ -0,0 +1,23 @@ +{{- if .Values.operator.vaultSecretBackend }} + {{- if .Values.operator.vaultSecretBackend.enabled }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: secret-configuration + namespace: {{ include "mongodb-enterprise-operator.namespace" . }} +data: + {{- if .Values.operator.vaultSecretBackend.tlsSecretRef }} + VAULT_SERVER_ADDRESS: https://vault.vault.svc.cluster.local:8200 + {{ else }} + VAULT_SERVER_ADDRESS: http://vault.vault.svc.cluster.local:8200 + {{ end }} + OPERATOR_SECRET_BASE_PATH: mongodbenterprise/operator + DATABASE_SECRET_BASE_PATH: mongodbenterprise/database + OPS_MANAGER_SECRET_BASE_PATH: mongodbenterprise/opsmanager + APPDB_SECRET_BASE_PATH: mongodbenterprise/appdb + {{- if .Values.operator.vaultSecretBackend.tlsSecretRef }} + TLS_SECRET_REF: vault-tls + {{ end }} +{{ end }} +{{ end }} diff --git a/helm_chart/values-openshift.yaml b/helm_chart/values-openshift.yaml new file mode 100644 index 000000000..ca3c63913 --- /dev/null +++ b/helm_chart/values-openshift.yaml @@ -0,0 +1,197 @@ +# Name of the Namespace to use +namespace: mongodb + +# OpenShift manages security context on its own +managedSecurityContext: true + +operator: + # Execution environment for the operator, dev or prod. Use dev for more verbose logging + env: prod + + # Name that will be assigned to most of the internal Kubernetes objects like ServiceAccount, Role etc. + name: mongodb-enterprise-operator + + # Name of the operator image + operator_image_name: mongodb-enterprise-operator-ubi + + # Version of mongodb-enterprise-operator + version: 1.20.0 + + # The Custom Resources that will be watched by the Operator. Needs to be changed if only some of the CRDs are installed + watchedResources: + - mongodb + - opsmanagers + - mongodbusers + + nodeSelector: {} + + tolerations: [] + + affinity: {} + +# operator cpu requests and limits +resources: + requests: + cpu: 500m + memory: 200Mi + limits: + cpu: 1100m + memory: 1Gi + +## Database +database: + name: mongodb-enterprise-database-ubi + version: 2.0.2 + +initDatabase: + name: mongodb-enterprise-init-database-ubi + version: 1.0.17 + +## Ops Manager +opsManager: + name: mongodb-enterprise-ops-manager-ubi + +initOpsManager: + name: mongodb-enterprise-init-ops-manager-ubi + version: 1.0.11 + +initAppDb: + name: mongodb-enterprise-init-appdb-ubi + version: 1.0.17 + +agent: + name: mongodb-agent-ubi + version: 12.0.21.7698-1 + +mongodb: + name: mongodb-enterprise-server + repo: quay.io/mongodb + appdbAssumeOldFormat: false + +## Registry +registry: + # The pull secret must be specified + imagePullSecrets: + pullPolicy: Always + database: quay.io/mongodb + operator: quay.io/mongodb + initDatabase: quay.io/mongodb + initOpsManager: quay.io/mongodb + opsManager: quay.io/mongodb + initAppDb: quay.io/mongodb + appDb: quay.io/mongodb + agent: quay.io/mongodb + +# Versions listed here are used to populate RELATED_IMAGE_ env variables in the operator deployment. +# Environment variables prefixed with RELATED_IMAGE_ are used by operator-sdk to generate relatedImages section +# with sha256 digests pinning for the certified operator bundle with disconnected environment feature enabled. +# https://docs.openshift.com/container-platform/4.11/operators/operator_sdk/osdk-generating-csvs.html#olm-enabling-operator-for-restricted-network_osdk-generating-csvs +relatedImages: + opsManager: + - 5.0.0 + - 5.0.1 + - 5.0.2 + - 5.0.3 + - 5.0.4 + - 5.0.5 + - 5.0.6 + - 5.0.7 + - 5.0.8 + - 5.0.9 + - 5.0.10 + - 5.0.11 + - 5.0.12 + - 5.0.13 + - 5.0.14 + - 5.0.15 + - 5.0.16 + - 5.0.17 + - 5.0.18 + - 5.0.19 + - 5.0.20 + - 6.0.0 + - 6.0.1 + - 6.0.2 + - 6.0.3 + - 6.0.4 + - 6.0.5 + - 6.0.6 + - 6.0.7 + - 6.0.8 + - 6.0.9 + - 6.0.10 + - 6.0.11 + - 6.0.12 + - 6.0.13 + mongodb: + - 4.4.0-ubi8 + - 4.4.1-ubi8 + - 4.4.2-ubi8 + - 4.4.3-ubi8 + - 4.4.4-ubi8 + - 4.4.5-ubi8 + - 4.4.6-ubi8 + - 4.4.7-ubi8 + - 4.4.8-ubi8 + - 4.4.9-ubi8 + - 4.4.10-ubi8 + - 4.4.11-ubi8 + - 4.4.12-ubi8 + - 4.4.13-ubi8 + - 4.4.14-ubi8 + - 4.4.15-ubi8 + - 4.4.16-ubi8 + - 4.4.17-ubi8 + - 4.4.18-ubi8 + - 4.4.19-ubi8 + - 4.4.20-ubi8 + - 4.4.21-ubi8 + - 5.0.0-ubi8 + - 5.0.1-ubi8 + - 5.0.2-ubi8 + - 5.0.3-ubi8 + - 5.0.4-ubi8 + - 5.0.5-ubi8 + - 5.0.6-ubi8 + - 5.0.7-ubi8 + - 5.0.8-ubi8 + - 5.0.9-ubi8 + - 5.0.10-ubi8 + - 5.0.11-ubi8 + - 5.0.12-ubi8 + - 5.0.13-ubi8 + - 5.0.14-ubi8 + - 5.0.15-ubi8 + - 5.0.16-ubi8 + - 5.0.17-ubi8 + - 5.0.18-ubi8 + - 6.0.0-ubi8 + - 6.0.1-ubi8 + - 6.0.2-ubi8 + - 6.0.3-ubi8 + - 6.0.4-ubi8 + - 6.0.5-ubi8 + agent: + - 11.0.5.6963-1 + - 11.12.0.7388-1 + - 12.0.4.7554-1 + - 12.0.15.7646-1 + - 12.0.20.7686-1 + - 12.0.21.7698-1 + mongodbLegacyAppDb: + - 4.2.11-ent + - 4.2.2-ent + - 4.2.24-ent + - 4.2.6-ent + - 4.2.8-ent + - 4.4.0-ent + - 4.4.11-ent + - 4.4.4-ent + - 4.4.21-ent + - 5.0.1-ent + - 5.0.5-ent + - 5.0.6-ent + - 5.0.7-ent + - 5.0.14-ent + - 5.0.18-ent +subresourceEnabled: true diff --git a/helm_chart/values.yaml b/helm_chart/values.yaml new file mode 100644 index 000000000..a7833f415 --- /dev/null +++ b/helm_chart/values.yaml @@ -0,0 +1,114 @@ +## Operator + +# Set this to true if your cluster is managing SecurityContext for you. +# If running OpenShift (Cloud, Minishift, etc.), set this to true. +managedSecurityContext: false + +operator: + # Execution environment for the operator, dev or prod. Use dev for more verbose logging + env: prod + + # Name that will be assigned to most internal Kubernetes objects like Deployment, ServiceAccount, Role etc. + name: mongodb-enterprise-operator + + # Name of the operator image + operator_image_name: mongodb-enterprise-operator + + # Name of the deployment of the operator pod + deployment_name: mongodb-enterprise-operator + + # Version of mongodb-enterprise-operator + version: 1.20.0 + + # The Custom Resources that will be watched by the Operator. Needs to be changed if only some of the CRDs are installed + watchedResources: + - mongodb + - opsmanagers + - mongodbusers + + nodeSelector: {} + + tolerations: [] + + affinity: {} + + # operator cpu requests and limits + resources: + requests: + cpu: 500m + memory: 200Mi + limits: + cpu: 1100m + memory: 1Gi + + # Create operator-service account + createOperatorServiceAccount: true + + vaultSecretBackend: + # set to true if you want the operator to store secrets in Vault + enabled: false + tlsSecretRef: '' + + replicas: 1 + +## Database +database: + name: mongodb-enterprise-database-ubi + version: 2.0.2 + +initDatabase: + name: mongodb-enterprise-init-database-ubi + version: 1.0.17 + +## Ops Manager +opsManager: + name: mongodb-enterprise-ops-manager-ubi + +initOpsManager: + name: mongodb-enterprise-init-ops-manager-ubi + version: 1.0.11 + +## Application Database +initAppDb: + name: mongodb-enterprise-init-appdb-ubi + version: 1.0.17 + +agent: + name: mongodb-agent-ubi + version: 12.0.21.7698-1 + +mongodbLegacyAppDb: + name: mongodb-enterprise-appdb-database-ubi + repo: quay.io/mongodb + +mongodb: + name: mongodb-enterprise-server + repo: quay.io/mongodb + appdbAssumeOldFormat: false + imageType: ubi8 + + +## Registry +registry: + imagePullSecrets: + # TODO: specify for each image and move there? + pullPolicy: Always + # Specify if images are pulled from private registry + operator: quay.io/mongodb + database: quay.io/mongodb + initDatabase: quay.io/mongodb + initOpsManager: quay.io/mongodb + opsManager: quay.io/mongodb + initAppDb: quay.io/mongodb + appDb: quay.io/mongodb + agent: quay.io/mongodb + +multiCluster: + # Specify if we want to deploy the operator in multi-cluster mode + clusters: [] + kubeConfigSecretName: mongodb-enterprise-operator-multi-cluster-kubeconfig + performFailOver: true + clusterClientTimeout: 10 +# Set this to false to disable subresource utilization +# It might be required on some versions of Openshift +subresourceEnabled: true diff --git a/inventories/daily.yaml b/inventories/daily.yaml new file mode 100644 index 000000000..dfe76973c --- /dev/null +++ b/inventories/daily.yaml @@ -0,0 +1,44 @@ +vars: + # these variables are configured from the outside, in pipeline.py::image_config + + quay_registry: quay.io/mongodb/ + s3_bucket_http: https://enterprise-operator-dockerfiles.s3.amazonaws.com/dockerfiles/ + ecr_registry_ubi: 268558157000.dkr.ecr.us-east-1.amazonaws.com/images/ubi/ + # ubi suffix is "-ubi" by default, but it's empty for mongodb-kubernetes-operator, readiness and versionhook images + ubi_suffix: "-ubi" + + +images: + - name: image-daily-build + vars: + context: . + + platform: linux/amd64 + + stages: + - name: build-ubi + task_type: docker_build + tags: ["ubi"] + + inputs: + - build_id + + dockerfile: $(inputs.params.s3_bucket_http)/$(inputs.params.release_version)/ubi/Dockerfile + buildargs: + imagebase: $(inputs.params.quay_registry):$(inputs.params.release_version)-context + # This is required for correctly labeling the agent image and is not used + # in the other images. + agent_version: $(inputs.params.release_version) + + output: + - registry: $(inputs.params.quay_registry)$(inputs.params.ubi_suffix) + tag: $(inputs.params.release_version) + - registry: $(inputs.params.quay_registry)$(inputs.params.ubi_suffix) + tag: $(inputs.params.release_version)-b$(inputs.params.build_id) + # Below two coordinates are on pair with the e2e_om_ops_manager_upgrade test but + # doesn't seem to reflect the way we push things to Quay. + # The proper fix should be addressed in https://jira.mongodb.org/browse/CLOUDP-133709 + - registry: $(inputs.params.ecr_registry_ubi)$(inputs.params.ubi_suffix) + tag: $(inputs.params.release_version) + - registry: $(inputs.params.ecr_registry_ubi)$(inputs.params.ubi_suffix) + tag: $(inputs.params.release_version)-b$(inputs.params.build_id) diff --git a/inventories/database.yaml b/inventories/database.yaml new file mode 100644 index 000000000..27c005657 --- /dev/null +++ b/inventories/database.yaml @@ -0,0 +1,71 @@ +vars: + quay_registry: quay.io/mongodb/mongodb-enterprise-database + s3_bucket: s3://enterprise-operator-dockerfiles/dockerfiles/mongodb-enterprise-database + +images: +- name: database + vars: + context: docker/mongodb-enterprise-database + + platform: linux/amd64 + + stages: + - name: database-build-context + task_type: docker_build + dockerfile: Dockerfile.builder + + output: + - registry: $(inputs.params.registry)/ubi/mongodb-enterprise-database-context + tag: $(inputs.params.version_id) + + - name: init-appdb-template-ubi + task_type: dockerfile_template + distro: ubi + tags: ["ubi"] + + inputs: + - version + + output: + - dockerfile: $(functions.tempfile) + + - name: database-build-ubi + task_type: docker_build + dockerfile: $(stages['init-appdb-template-ubi'].outputs[0].dockerfile) + tags: ["ubi"] + + buildargs: + imagebase: $(inputs.params.registry)/ubi/mongodb-enterprise-database-context:$(inputs.params.version_id) + + output: + - registry: $(inputs.params.registry)/ubi/mongodb-enterprise-database + tag: $(inputs.params.version_id) + - registry: $(inputs.params.registry)/ubi/mongodb-enterprise-database + tag: latest + - registry: $(inputs.params.registry)/ubi/mongodb-enterprise-database-ubi + tag: $(inputs.params.version_id) + - registry: $(inputs.params.registry)/ubi/mongodb-enterprise-database-ubi + tag: latest + + - name: database-release-context + task_type: tag_image + tags: ["release"] + + source: + registry: $(inputs.params.registry)/ubi/mongodb-enterprise-database-context + tag: $(inputs.params.version_id) + + destination: + - registry: $(inputs.params.quay_registry) + tag: $(inputs.params.version)-context + + - name: database-template-ubi + task_type: dockerfile_template + distro: ubi + tags: ["release"] + + inputs: + - version + + output: + - dockerfile: $(inputs.params.s3_bucket)/$(inputs.params.version)/ubi/Dockerfile diff --git a/inventories/init_appdb.yaml b/inventories/init_appdb.yaml new file mode 100644 index 000000000..d4754f59d --- /dev/null +++ b/inventories/init_appdb.yaml @@ -0,0 +1,82 @@ +vars: + readiness_probe_repo: quay.io/mongodb/mongodb-kubernetes-readinessprobe + quay_registry: quay.io/mongodb/mongodb-enterprise-init-appdb + s3_bucket: s3://enterprise-operator-dockerfiles/dockerfiles/mongodb-enterprise-init-appdb + +images: +- name: init-appdb + vars: + context: . + template_context: docker/mongodb-enterprise-init-database + + platform: linux/amd64 + + stages: + - name: init-appdb-build-context + task_type: docker_build + dockerfile: docker/mongodb-enterprise-init-database/Dockerfile.builder + + buildargs: + mongodb_tools_url_ubi: $(inputs.params.mongodb_tools_url_ubi) + readiness_probe_version: $(inputs.params.readiness_probe_version) + readiness_probe_repo: $(inputs.params.readiness_probe_repo) + version_upgrade_post_start_hook_version: $(inputs.params.version_upgrade_post_start_hook_version) + + output: + - registry: $(inputs.params.registry)/ubi/mongodb-enterprise-init-appdb-context + tag: $(inputs.params.version_id) + + + - name: init-appdb-template-ubi + task_type: dockerfile_template + template_file_extension: ubi_minimal + tags: ["ubi"] + + inputs: + - is_appdb + + output: + - dockerfile: $(functions.tempfile) + + - name: init-appdb-build-ubi + task_type: docker_build + tags: ["ubi"] + + buildargs: + version: $(inputs.params.version) + imagebase: $(inputs.params.registry)/ubi/mongodb-enterprise-init-appdb-context:$(inputs.params.version_id) + + dockerfile: $(stages['init-appdb-template-ubi'].outputs[0].dockerfile) + + output: + - registry: $(inputs.params.registry)/ubi/mongodb-enterprise-init-appdb + tag: $(inputs.params.version_id) + - registry: $(inputs.params.registry)/ubi/mongodb-enterprise-init-appdb + tag: latest + - registry: $(inputs.params.registry)/ubi/mongodb-enterprise-init-appdb-ubi + tag: $(inputs.params.version_id) + - registry: $(inputs.params.registry)/ubi/mongodb-enterprise-init-appdb-ubi + tag: latest + + - name: init-appdb-release-context + task_type: tag_image + tags: ["release"] + + source: + registry: $(inputs.params.registry)/ubi/mongodb-enterprise-init-appdb-context + tag: $(inputs.params.version_id) + + destination: + - registry: $(inputs.params.quay_registry) + tag: $(inputs.params.version)-context + + - name: init-appdb-template-ubi + task_type: dockerfile_template + template_file_extension: ubi_minimal + tags: ["release"] + + inputs: + - is_appdb + + output: + - dockerfile: $(inputs.params.s3_bucket)/$(inputs.params.version)/ubi/Dockerfile diff --git a/inventories/init_database.yaml b/inventories/init_database.yaml new file mode 100644 index 000000000..65a7582d7 --- /dev/null +++ b/inventories/init_database.yaml @@ -0,0 +1,83 @@ +vars: + readiness_probe_repo: quay.io/mongodb/mongodb-kubernetes-readinessprobe + quay_registry: quay.io/mongodb/mongodb-enterprise-init-database + s3_bucket: s3://enterprise-operator-dockerfiles/dockerfiles/mongodb-enterprise-init-database + + +images: + - name: init-database + vars: + context: . + template_context: docker/mongodb-enterprise-init-database + + platform: linux/amd64 + + stages: + - name: init-database-build-context + task_type: docker_build + dockerfile: docker/mongodb-enterprise-init-database/Dockerfile.builder + + buildargs: + mongodb_tools_url_ubi: $(inputs.params.mongodb_tools_url_ubi) + readiness_probe_version: $(inputs.params.readiness_probe_version) + readiness_probe_repo: $(inputs.params.readiness_probe_repo) + version_upgrade_post_start_hook_version: $(inputs.params.version_upgrade_post_start_hook_version) + + output: + - registry: $(inputs.params.registry)/ubi/mongodb-enterprise-init-database-context + tag: $(inputs.params.version_id) + + - name: init-database-template-ubi + task_type: dockerfile_template + template_file_extension: ubi_minimal + tags: ["ubi"] + + inputs: + - is_appdb + output: + - dockerfile: $(functions.tempfile) + + - name: init-database-build-ubi + task_type: docker_build + tags: ["ubi"] + + buildargs: + imagebase: $(inputs.params.registry)/ubi/mongodb-enterprise-init-database-context:$(inputs.params.version_id) + version: $(inputs.params.version) + + dockerfile: $(stages['init-database-template-ubi'].outputs[0].dockerfile) + inputs: + - is_appdb + + output: + - registry: $(inputs.params.registry)/ubi/mongodb-enterprise-init-database + tag: $(inputs.params.version_id) + - registry: $(inputs.params.registry)/ubi/mongodb-enterprise-init-database + tag: latest + - registry: $(inputs.params.registry)/ubi/mongodb-enterprise-init-database-ubi + tag: $(inputs.params.version_id) + - registry: $(inputs.params.registry)/ubi/mongodb-enterprise-init-database-ubi + tag: latest + + - name: init-database-release-context + task_type: tag_image + tags: ["release"] + + source: + registry: $(inputs.params.registry)/ubi/mongodb-enterprise-init-database-context + tag: $(inputs.params.version_id) + + destination: + - registry: $(inputs.params.quay_registry) + tag: $(inputs.params.version)-context + + - name: init-database-template-ubi + task_type: dockerfile_template + template_file_extension: ubi_minimal + tags: ["release"] + + inputs: + - is_appdb + + output: + - dockerfile: $(inputs.params.s3_bucket)/$(inputs.params.version)/ubi/Dockerfile diff --git a/inventories/init_om.yaml b/inventories/init_om.yaml new file mode 100644 index 000000000..2317cf07b --- /dev/null +++ b/inventories/init_om.yaml @@ -0,0 +1,72 @@ +vars: + quay_registry: quay.io/mongodb/mongodb-enterprise-init-ops-manager + s3_bucket: s3://enterprise-operator-dockerfiles/dockerfiles/mongodb-enterprise-init-ops-manager + +images: +- name: init-ops-manager + vars: + context: docker/mongodb-enterprise-init-ops-manager + + platform: linux/amd64 + + stages: + - name: init-ops-manager-build-context + task_type: docker_build + dockerfile: Dockerfile.builder + + output: + - registry: $(inputs.params.registry)/ubi/mongodb-enterprise-init-ops-manager-context + tag: $(inputs.params.version_id) + + + - name: init-ops-manager-template-ubi + task_type: dockerfile_template + template_file_extension: ubi_minimal + tags: ["ubi"] + + inputs: + - version + + output: + - dockerfile: $(functions.tempfile) + + - name: init-ops-manager-build-ubi + task_type: docker_build + dockerfile: $(stages['init-ops-manager-template-ubi'].outputs[0].dockerfile) + tags: ["ubi"] + + buildargs: + imagebase: $(inputs.params.registry)/ubi/mongodb-enterprise-init-ops-manager-context:$(inputs.params.version_id) + + output: + - registry: $(inputs.params.registry)/ubi/mongodb-enterprise-init-ops-manager + tag: $(inputs.params.version_id) + - registry: $(inputs.params.registry)/ubi/mongodb-enterprise-init-ops-manager + tag: latest + - registry: $(inputs.params.registry)/ubi/mongodb-enterprise-init-ops-manager-ubi + tag: $(inputs.params.version_id) + - registry: $(inputs.params.registry)/ubi/mongodb-enterprise-init-ops-manager-ubi + tag: latest + + - name: init-ops-manager-release-context + task_type: tag_image + tags: ["release"] + + source: + registry: $(inputs.params.registry)/ubi/mongodb-enterprise-init-ops-manager-context + tag: $(inputs.params.version_id) + + destination: + - registry: $(inputs.params.quay_registry) + tag: $(inputs.params.version)-context + + - name: init-ops-manager-template-ubi + task_type: dockerfile_template + template_file_extension: ubi_minimal + tags: ["release"] + + inputs: + - version + + output: + - dockerfile: $(inputs.params.s3_bucket)/$(inputs.params.version)/ubi/Dockerfile \ No newline at end of file diff --git a/inventories/om.yaml b/inventories/om.yaml new file mode 100644 index 000000000..73b36cbee --- /dev/null +++ b/inventories/om.yaml @@ -0,0 +1,97 @@ +vars: + quay_registry: quay.io/mongodb/mongodb-enterprise-ops-manager + s3_bucket: s3://enterprise-operator-dockerfiles/dockerfiles/mongodb-enterprise-ops-manager + om_registry: 268558157000.dkr.ecr.us-east-1.amazonaws.com + +images: +- name: ops-manager + vars: + context: docker/mongodb-enterprise-ops-manager + + platform: linux/amd64 + + stages: + - name: ops-manager-context + task_type: docker_build + + dockerfile: Dockerfile.builder + + output: + - registry: $(inputs.params.registry)/ops-manager-context + tag: $(inputs.params.version_id) + + + - name: ops-manager-template-ubi + task_type: dockerfile_template + template_file_extension: ubi + tags: ["ubi"] + + inputs: + - om_download_url + - om_version + + buildargs: + imagebase: $(inputs.params.registry)/ops-manager-context:$(inputs.params.version_id) + + output: + - dockerfile: $(functions.tempfile) + + - name: ops-manager-build + task_type: docker_build + dockerfile: $(stages['ops-manager-template-ubi'].outputs[0].dockerfile) + tags: ["ubi"] + + buildargs: + imagebase: $(inputs.params.registry)/ops-manager-context:$(inputs.params.version_id) + + output: + - registry: $(inputs.params.om_registry)/images/ubi/mongodb-enterprise-ops-manager + tag: $(inputs.params.om_version) + - registry: $(inputs.params.om_registry)/images/ubi/mongodb-enterprise-ops-manager-ubi + tag: $(inputs.params.om_version) + + ## Release tasks + - name: ops-manager-template + task_type: dockerfile_template + template_file_extension: ubi + tags: ["ubi", "release"] + + inputs: + - om_download_url + - om_version + + output: + - dockerfile: $(inputs.params.s3_bucket)/$(inputs.params.om_version)/ubi/Dockerfile + + - name: ops-manager-context-release + task_type: tag_image + tags: ["release"] + + source: + registry: $(inputs.params.registry)/ops-manager-context + tag: $(inputs.params.version_id) + + destination: + - registry: $(inputs.params.quay_registry) + tag: $(inputs.params.om_version)-context + +- name: ops-manager-daily + vars: + context: docker/mongodb-enterprise-ops-manager + + stages: + - name: build-ubi + task_type: docker_build + + inputs: + - build_id + + dockerfile: $(inputs.params.s3_bucket_http)/$(inputs.params.release_version)/ubi/Dockerfile + buildargs: + imagebase: $(inputs.params.quay_registry):$(inputs.params.release_version)-context + + output: + - registry: $(inputs.params.quay_registry)-ubi + tag: $(inputs.params.release_version) + - registry: $(inputs.params.quay_registry)-ubi + tag: $(inputs.params.release_version)-b$(inputs.params.build_id) diff --git a/inventories/test.yaml b/inventories/test.yaml new file mode 100644 index 000000000..2b245b07c --- /dev/null +++ b/inventories/test.yaml @@ -0,0 +1,15 @@ +images: +- name: test + vars: + context: docker/mongodb-enterprise-tests + platform: linux/amd64 + + stages: + - name: build + task_type: docker_build + dockerfile: Dockerfile + output: + - registry: $(inputs.params.registry)/mongodb-enterprise-tests + tag: latest + - registry: $(inputs.params.registry)/mongodb-enterprise-tests + tag: $(inputs.params.version_id) diff --git a/inventory.yaml b/inventory.yaml new file mode 100644 index 000000000..dda18e13f --- /dev/null +++ b/inventory.yaml @@ -0,0 +1,82 @@ +vars: + registry: + quay_registry: quay.io/mongodb/mongodb-enterprise-operator + s3_bucket: s3://enterprise-operator-dockerfiles/dockerfiles/mongodb-enterprise-operator + +images: + - name: operator + vars: + context: . + template_context: docker/mongodb-enterprise-operator + + platform: linux/amd64 + inputs: + - release_version + - log_automation_config_diff + + stages: + - name: operator-context-dockerfile + task_type: docker_build + + dockerfile: docker/mongodb-enterprise-operator/Dockerfile.builder + buildargs: + release_version: $(inputs.params.release_version) + log_automation_config_diff: $(inputs.params.log_automation_config_diff) + + output: + - registry: $(inputs.params.registry)/operator-context + tag: $(inputs.params.version_id) + + - name: operator-template-ubi + task_type: dockerfile_template + tags: ["ubi"] + distro: ubi + + inputs: + - release_version + - debug + + output: + - dockerfile: $(functions.tempfile) + + + - name: operator-ubi-build + task_type: docker_build + tags: ["ubi"] + + dockerfile: $(stages['operator-template-ubi'].outputs[0].dockerfile) + buildargs: + imagebase: $(inputs.params.registry)/operator-context:$(inputs.params.version_id) + + output: + - registry: $(inputs.params.registry)/ubi/mongodb-enterprise-operator + tag: $(inputs.params.version_id) + - registry: $(inputs.params.registry)/ubi/mongodb-enterprise-operator + tag: latest + - registry: $(inputs.params.registry)/ubi/mongodb-enterprise-operator-ubi + tag: $(inputs.params.version_id) + - registry: $(inputs.params.registry)/ubi/mongodb-enterprise-operator-ubi + tag: latest + + - name: operator-context-release + task_type: tag_image + tags: ["release"] + + source: + registry: $(inputs.params.registry)/operator-context + tag: $(inputs.params.version_id) + + destination: + - registry: $(inputs.params.quay_registry) + tag: $(inputs.params.release_version)-context + + - name: operator-template-ubi + task_type: dockerfile_template + tags: ["release"] + distro: ubi + + inputs: + - release_version + + output: + - dockerfile: $(inputs.params.s3_bucket)/$(inputs.params.release_version)/ubi/Dockerfile diff --git a/licenses.csv b/licenses.csv new file mode 100644 index 000000000..c0baa6569 --- /dev/null +++ b/licenses.csv @@ -0,0 +1,105 @@ + +github.com/PuerkitoBio/purell,v1.1.1,https://github.com/PuerkitoBio/purell/blob/v1.1.1/LICENSE,BSD-3-Clause +github.com/PuerkitoBio/urlesc,v0.0.0-20170810143723-de5bf2ad4578,https://github.com/PuerkitoBio/urlesc/blob/de5bf2ad4578/LICENSE,BSD-3-Clause +github.com/armon/go-metrics,v0.3.9,https://github.com/armon/go-metrics/blob/v0.3.9/LICENSE,MIT +github.com/armon/go-radix,v1.0.0,https://github.com/armon/go-radix/blob/v1.0.0/LICENSE,MIT +github.com/beorn7/perks/quantile,v1.0.1,https://github.com/beorn7/perks/blob/v1.0.1/LICENSE,MIT +github.com/blang/semver,v3.5.1,https://github.com/blang/semver/blob/v3.5.1/LICENSE,MIT +github.com/cenkalti/backoff/v3,v3.0.0,https://github.com/cenkalti/backoff/blob/v3.0.0/LICENSE,MIT +github.com/cespare/xxhash/v2,v2.2.0,https://github.com/cespare/xxhash/blob/v2.2.0/LICENSE.txt,MIT +github.com/davecgh/go-spew/spew,v1.1.1,https://github.com/davecgh/go-spew/blob/v1.1.1/LICENSE,ISC +github.com/emicklei/go-restful,v2.9.6,https://github.com/emicklei/go-restful/blob/v2.9.6/LICENSE,MIT +github.com/evanphx/json-patch,v5.6.0,https://github.com/evanphx/json-patch/blob/v5.6.0/LICENSE,BSD-3-Clause +github.com/fatih/color,v1.7.0,https://github.com/fatih/color/blob/v1.7.0/LICENSE.md,MIT +github.com/fsnotify/fsnotify,v1.5.1,https://github.com/fsnotify/fsnotify/blob/v1.5.1/LICENSE,BSD-3-Clause +github.com/ghodss/yaml,v1.0.0,https://github.com/ghodss/yaml/blob/v1.0.0/LICENSE,MIT +github.com/go-logr/logr,v1.2.3,https://github.com/go-logr/logr/blob/v1.2.3/LICENSE,Apache-2.0 +github.com/go-openapi/jsonpointer,v0.19.5,https://github.com/go-openapi/jsonpointer/blob/v0.19.5/LICENSE,Apache-2.0 +github.com/go-openapi/jsonreference,v0.19.5,https://github.com/go-openapi/jsonreference/blob/v0.19.5/LICENSE,Apache-2.0 +github.com/go-openapi/swag,v0.19.14,https://github.com/go-openapi/swag/blob/v0.19.14/LICENSE,Apache-2.0 +github.com/gogo/protobuf,v1.3.2,https://github.com/gogo/protobuf/blob/v1.3.2/LICENSE,BSD-3-Clause +github.com/golang/groupcache/lru,v0.0.0-20210331224755-41bb18bfe9da,https://github.com/golang/groupcache/blob/41bb18bfe9da/LICENSE,Apache-2.0 +github.com/golang/protobuf,v1.5.3,https://github.com/golang/protobuf/blob/v1.5.3/LICENSE,BSD-3-Clause +github.com/golang/snappy,v0.0.4,https://github.com/golang/snappy/blob/v0.0.4/LICENSE,BSD-3-Clause +github.com/google/gnostic,v0.5.7-v3refs,https://github.com/google/gnostic/blob/v0.5.7-v3refs/LICENSE,Apache-2.0 +github.com/google/go-cmp/cmp,v0.5.9,https://github.com/google/go-cmp/blob/v0.5.9/LICENSE,BSD-3-Clause +github.com/google/gofuzz,v1.1.0,https://github.com/google/gofuzz/blob/v1.1.0/LICENSE,Apache-2.0 +github.com/google/uuid,v1.3.0,https://github.com/google/uuid/blob/v1.3.0/LICENSE,BSD-3-Clause +github.com/hashicorp/errwrap,v1.1.0,https://github.com/hashicorp/errwrap/blob/v1.1.0/LICENSE,MPL-2.0 +github.com/hashicorp/go-cleanhttp,v0.5.2,https://github.com/hashicorp/go-cleanhttp/blob/v0.5.2/LICENSE,MPL-2.0 +github.com/hashicorp/go-hclog,v0.16.2,https://github.com/hashicorp/go-hclog/blob/v0.16.2/LICENSE,MIT +github.com/hashicorp/go-immutable-radix,v1.3.1,https://github.com/hashicorp/go-immutable-radix/blob/v1.3.1/LICENSE,MPL-2.0 +github.com/hashicorp/go-multierror,v1.1.1,https://github.com/hashicorp/go-multierror/blob/v1.1.1/LICENSE,MPL-2.0 +github.com/hashicorp/go-plugin,v1.4.5,https://github.com/hashicorp/go-plugin/blob/v1.4.5/LICENSE,MPL-2.0 +github.com/hashicorp/go-retryablehttp,v0.7.2,https://github.com/hashicorp/go-retryablehttp/blob/v0.7.2/LICENSE,MPL-2.0 +github.com/hashicorp/go-rootcerts,v1.0.2,https://github.com/hashicorp/go-rootcerts/blob/v1.0.2/LICENSE,MPL-2.0 +github.com/hashicorp/go-secure-stdlib/mlock,v0.1.1,https://github.com/hashicorp/go-secure-stdlib/blob/mlock/v0.1.1/mlock/LICENSE,MPL-2.0 +github.com/hashicorp/go-secure-stdlib/parseutil,v0.1.6,https://github.com/hashicorp/go-secure-stdlib/blob/parseutil/v0.1.6/parseutil/LICENSE,MPL-2.0 +github.com/hashicorp/go-secure-stdlib/strutil,v0.1.2,https://github.com/hashicorp/go-secure-stdlib/blob/strutil/v0.1.2/strutil/LICENSE,MPL-2.0 +github.com/hashicorp/go-sockaddr,v1.0.2,https://github.com/hashicorp/go-sockaddr/blob/v1.0.2/LICENSE,MPL-2.0 +github.com/hashicorp/go-uuid,v1.0.2,https://github.com/hashicorp/go-uuid/blob/v1.0.2/LICENSE,MPL-2.0 +github.com/hashicorp/go-version,v1.2.0,https://github.com/hashicorp/go-version/blob/v1.2.0/LICENSE,MPL-2.0 +github.com/hashicorp/golang-lru,v0.5.4,https://github.com/hashicorp/golang-lru/blob/v0.5.4/LICENSE,MPL-2.0 +github.com/hashicorp/hcl,v1.0.0,https://github.com/hashicorp/hcl/blob/v1.0.0/LICENSE,MPL-2.0 +github.com/hashicorp/vault/api,v1.8.3,https://github.com/hashicorp/vault/blob/api/v1.8.3/api/LICENSE,MPL-2.0 +github.com/hashicorp/vault/sdk,v0.7.0,https://github.com/hashicorp/vault/blob/sdk/v0.7.0/sdk/LICENSE,MPL-2.0 +github.com/hashicorp/yamux,v0.0.0-20180604194846-3520598351bb,https://github.com/hashicorp/yamux/blob/3520598351bb/LICENSE,MPL-2.0 +github.com/imdario/mergo,v0.3.15,https://github.com/imdario/mergo/blob/v0.3.15/LICENSE,BSD-3-Clause +github.com/josharian/intern,v1.0.0,https://github.com/josharian/intern/blob/v1.0.0/license.md,MIT +github.com/json-iterator/go,v1.1.12,https://github.com/json-iterator/go/blob/v1.1.12/LICENSE,MIT +github.com/mailru/easyjson,v0.7.6,https://github.com/mailru/easyjson/blob/v0.7.6/LICENSE,MIT +github.com/mattn/go-colorable,v0.1.6,https://github.com/mattn/go-colorable/blob/v0.1.6/LICENSE,MIT +github.com/mattn/go-isatty,v0.0.12,https://github.com/mattn/go-isatty/blob/v0.0.12/LICENSE,MIT +github.com/matttproud/golang_protobuf_extensions/pbutil,v1.0.4,https://github.com/matttproud/golang_protobuf_extensions/blob/v1.0.4/LICENSE,Apache-2.0 +github.com/mitchellh/copystructure,v1.0.0,https://github.com/mitchellh/copystructure/blob/v1.0.0/LICENSE,MIT +github.com/mitchellh/go-testing-interface,v1.0.0,https://github.com/mitchellh/go-testing-interface/blob/v1.0.0/LICENSE,MIT +github.com/mitchellh/mapstructure,v1.5.0,https://github.com/mitchellh/mapstructure/blob/v1.5.0/LICENSE,MIT +github.com/mitchellh/reflectwalk,v1.0.0,https://github.com/mitchellh/reflectwalk/blob/v1.0.0/LICENSE,MIT +github.com/modern-go/concurrent,v0.0.0-20180306012644-bacd9c7ef1dd,https://github.com/modern-go/concurrent/blob/bacd9c7ef1dd/LICENSE,Apache-2.0 +github.com/modern-go/reflect2,v1.0.2,https://github.com/modern-go/reflect2/blob/v1.0.2/LICENSE,Apache-2.0 +github.com/munnerz/goautoneg,v0.0.0-20191010083416-a7dc8b61c822,https://github.com/munnerz/goautoneg/blob/a7dc8b61c822/LICENSE,BSD-3-Clause +github.com/oklog/run,v1.0.0,https://github.com/oklog/run/blob/v1.0.0/LICENSE,Apache-2.0 +github.com/pierrec/lz4,v2.5.2,https://github.com/pierrec/lz4/blob/v2.5.2/LICENSE,BSD-3-Clause +github.com/pkg/errors,v0.9.1,https://github.com/pkg/errors/blob/v0.9.1/LICENSE,BSD-2-Clause +github.com/pmezard/go-difflib/difflib,v1.0.0,https://github.com/pmezard/go-difflib/blob/v1.0.0/LICENSE,BSD-3-Clause +github.com/prometheus/client_golang/prometheus,v1.14.0,https://github.com/prometheus/client_golang/blob/v1.14.0/LICENSE,Apache-2.0 +github.com/prometheus/client_model/go,v0.3.0,https://github.com/prometheus/client_model/blob/v0.3.0/LICENSE,Apache-2.0 +github.com/prometheus/common,v0.39.0,https://github.com/prometheus/common/blob/v0.39.0/LICENSE,Apache-2.0 +github.com/prometheus/common/internal/bitbucket.org/ww/goautoneg,v0.39.0,https://github.com/prometheus/common/blob/v0.39.0/internal/bitbucket.org/ww/goautoneg/README.txt,BSD-3-Clause +github.com/prometheus/procfs,v0.8.0,https://github.com/prometheus/procfs/blob/v0.8.0/LICENSE,Apache-2.0 +github.com/r3labs/diff/v3,v3.0.1,https://github.com/r3labs/diff/blob/v3.0.1/LICENSE,MPL-2.0 +github.com/ryanuber/go-glob,v1.0.0,https://github.com/ryanuber/go-glob/blob/v1.0.0/LICENSE,MIT +github.com/spf13/cast,v1.5.1,https://github.com/spf13/cast/blob/v1.5.1/LICENSE,MIT +github.com/spf13/pflag,v1.0.5,https://github.com/spf13/pflag/blob/v1.0.5/LICENSE,BSD-3-Clause +github.com/stretchr/objx,v0.5.0,https://github.com/stretchr/objx/blob/v0.5.0/LICENSE,MIT +github.com/stretchr/testify/assert,v1.8.2,https://github.com/stretchr/testify/blob/v1.8.2/LICENSE,MIT +github.com/vmihailenco/msgpack/v5,v5.3.5,https://github.com/vmihailenco/msgpack/blob/v5.3.5/LICENSE,BSD-2-Clause +github.com/vmihailenco/tagparser/v2,v2.0.0,https://github.com/vmihailenco/tagparser/blob/v2.0.0/LICENSE,BSD-2-Clause +github.com/xdg/stringprep,v1.0.3,https://github.com/xdg/stringprep/blob/v1.0.3/LICENSE,Apache-2.0 +go.uber.org/atomic,v1.9.0,https://github.com/uber-go/atomic/blob/v1.9.0/LICENSE.txt,MIT +go.uber.org/multierr,v1.6.0,https://github.com/uber-go/multierr/blob/v1.6.0/LICENSE.txt,MIT +go.uber.org/zap,v1.24.0,https://github.com/uber-go/zap/blob/v1.24.0/LICENSE.txt,MIT +gomodules.xyz/jsonpatch/v2,v2.2.0,https://github.com/gomodules/jsonpatch/blob/v2.2.0/v2/LICENSE,Apache-2.0 +google.golang.org/genproto/googleapis/rpc/status,v0.0.0-20230410155749-daa745c078e1,https://github.com/googleapis/go-genproto/blob/daa745c078e1/LICENSE,Apache-2.0 +google.golang.org/grpc,v1.55.0,https://github.com/grpc/grpc-go/blob/v1.55.0/LICENSE,Apache-2.0 +google.golang.org/protobuf,v1.30.0,https://github.com/protocolbuffers/protobuf-go/blob/v1.30.0/LICENSE,BSD-3-Clause +gopkg.in/inf.v0,v0.9.1,https://github.com/go-inf/inf/blob/v0.9.1/LICENSE,BSD-3-Clause +gopkg.in/square/go-jose.v2,v2.5.1,https://github.com/square/go-jose/blob/v2.5.1/LICENSE,Apache-2.0 +gopkg.in/square/go-jose.v2/json,v2.5.1,https://github.com/square/go-jose/blob/v2.5.1/json/LICENSE,BSD-3-Clause +gopkg.in/yaml.v2,v2.4.0,https://github.com/go-yaml/yaml/blob/v2.4.0/LICENSE,Apache-2.0 +gopkg.in/yaml.v3,v3.0.1,https://github.com/go-yaml/yaml/blob/v3.0.1/LICENSE,MIT +k8s.io/api,v0.24.14,https://github.com/kubernetes/api/blob/v0.24.14/LICENSE,Apache-2.0 +k8s.io/apiextensions-apiserver/pkg/apis/apiextensions,v0.24.14,https://github.com/kubernetes/apiextensions-apiserver/blob/v0.24.14/LICENSE,Apache-2.0 +k8s.io/apimachinery/pkg,v0.24.14,https://github.com/kubernetes/apimachinery/blob/v0.24.14/LICENSE,Apache-2.0 +k8s.io/apimachinery/third_party/forked/golang,v0.24.14,https://github.com/kubernetes/apimachinery/blob/v0.24.14/third_party/forked/golang/LICENSE,BSD-3-Clause +k8s.io/client-go,v0.24.14,https://github.com/kubernetes/client-go/blob/v0.24.14/LICENSE,Apache-2.0 +k8s.io/component-base/config,v0.24.14,https://github.com/kubernetes/component-base/blob/v0.24.14/LICENSE,Apache-2.0 +k8s.io/klog/v2,v2.60.1,https://github.com/kubernetes/klog/blob/v2.60.1/LICENSE,Apache-2.0 +k8s.io/kube-openapi/pkg,v0.0.0-20220328201542-3ee0da9b0b42,https://github.com/kubernetes/kube-openapi/blob/3ee0da9b0b42/LICENSE,Apache-2.0 +k8s.io/kube-openapi/pkg/validation/spec,v0.0.0-20220328201542-3ee0da9b0b42,https://github.com/kubernetes/kube-openapi/blob/3ee0da9b0b42/pkg/validation/spec/LICENSE,Apache-2.0 +k8s.io/utils,v0.0.0-20220210201930-3a6ce19ff2f9,https://github.com/kubernetes/utils/blob/3a6ce19ff2f9/LICENSE,Apache-2.0 +k8s.io/utils/internal/third_party/forked/golang/net,v0.0.0-20220210201930-3a6ce19ff2f9,https://github.com/kubernetes/utils/blob/3a6ce19ff2f9/internal/third_party/forked/golang/LICENSE,BSD-3-Clause +sigs.k8s.io/controller-runtime,v0.12.3,https://github.com/kubernetes-sigs/controller-runtime/blob/v0.12.3/LICENSE,Apache-2.0 +sigs.k8s.io/json,v0.0.0-20211208200746-9f7c6b3444d2,https://github.com/kubernetes-sigs/json/blob/9f7c6b3444d2/LICENSE,Apache-2.0 +sigs.k8s.io/structured-merge-diff/v4,v4.2.3,https://github.com/kubernetes-sigs/structured-merge-diff/blob/v4.2.3/LICENSE,Apache-2.0 +sigs.k8s.io/yaml,v1.3.0,https://github.com/kubernetes-sigs/yaml/blob/v1.3.0/LICENSE,MIT diff --git a/main.go b/main.go new file mode 100644 index 000000000..660787c2e --- /dev/null +++ b/main.go @@ -0,0 +1,349 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + localruntime "runtime" + "strconv" + "strings" + + apiv1 "github.com/10gen/ops-manager-kubernetes/api/v1" + "github.com/10gen/ops-manager-kubernetes/controllers" + "github.com/10gen/ops-manager-kubernetes/controllers/operator" + "github.com/10gen/ops-manager-kubernetes/pkg/multicluster" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" + "github.com/10gen/ops-manager-kubernetes/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/cache" + runtime_cluster "sigs.k8s.io/controller-runtime/pkg/cluster" + + "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/manager/signals" +) + +var ( + log *zap.SugaredLogger + + // List of allowed operator environments. The first element of this list is + // considered the default one. + operatorEnvironments = []string{util.OperatorEnvironmentDev, util.OperatorEnvironmentLocal, util.OperatorEnvironmentProd} + + scheme = runtime.NewScheme() +) + +const ( + mdbWebHookPortEnvName = "MDB_WEBHOOK_PORT" +) + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(apiv1.AddToScheme(scheme)) + utilruntime.Must(corev1.AddToScheme(scheme)) + + // +kubebuilder:scaffold:scheme +} + +// commandLineFlags struct holds the command line arguments passed to the operator deployment +type commandLineFlags struct { + crdsToWatch string +} + +// crdsToWatch is a custom Value implementation which can be +// used to receive command line arguments +type crdsToWatch []string + +func (c *crdsToWatch) Set(value string) error { + *c = append(*c, value) + return nil +} + +func (c *crdsToWatch) String() string { + return strings.Join(*c, ",") +} + +// parseCommandLineArgs parses the command line arguments passed in the operator deployment specs +func parseCommandLineArgs() commandLineFlags { + crds := crdsToWatch{} + + flag.Var(&crds, "watch-resource", "A Watch Resource specifies if the Operator should watch the given resource") + flag.Parse() + + return commandLineFlags{ + crdsToWatch: crds.String(), + } +} + +func main() { + initializeEnvironment() + + // Get a config to talk to the apiserver + cfg := ctrl.GetConfigOrDie() + + managerOptions := ctrl.Options{ + Scheme: scheme, + } + + // Namespace where the operator is installed + currentNamespace := env.ReadOrPanic(util.CurrentNamespace) + + namespacesToWatch := operator.GetWatchedNamespace() + if len(namespacesToWatch) == 1 { + // This will be the name of 1 namespace to watch, or the empty string + // for an operator that watches the whole cluster + managerOptions.Namespace = namespacesToWatch[0] + } else { + if !stringutil.Contains(namespacesToWatch, currentNamespace) { + namespacesToWatch = append(namespacesToWatch, currentNamespace) + } + // In multi-namespace scenarios, the namespace where the Operator + // resides needs to be part of the Cache as well. + managerOptions.NewCache = cache.MultiNamespacedCacheBuilder(namespacesToWatch) + } + + if isInLocalMode() { + managerOptions.MetricsBindAddress = "127.0.0.1:8180" + managerOptions.HealthProbeBindAddress = "127.0.0.1:8181" + } + + mgr, err := ctrl.NewManager(cfg, managerOptions) + if err != nil { + log.Fatal(err) + } + log.Info("Registering Components.") + + commandLineFlags := parseCommandLineArgs() + crdsToWatch := commandLineFlags.crdsToWatch + setupWebhook(mgr, cfg, log, multicluster.IsMultiClusterMode(crdsToWatch)) + + // Setup Scheme for all resources + if err := apiv1.AddToScheme(scheme); err != nil { + log.Fatal(err) + } + + // memberClusterObjectsMap is a map of clusterName -> clusterObject + memberClusterObjectsMap := make(map[string]runtime_cluster.Cluster) + + if multicluster.IsMultiClusterMode(crdsToWatch) { + kubeConfigFile, err := multicluster.NewKubeConfigFile() + if err != nil { + log.Fatalf("failed to open kubeconfig file: %s, err: %s", multicluster.GetKubeConfigPath(), err) + } + + kubeConfig, err := kubeConfigFile.LoadKubeConfigFile() + if err != nil { + log.Fatal("failed reading KubeConfig file: %s", err) + } + + memberClustersNames, err := getMemberClusters(cfg) + if err != nil { + log.Fatal(err) + } + + log.Infof("Watching Member clusters: %s", memberClustersNames) + + if len(memberClustersNames) == 0 { + log.Warnf("The operator did not detect any member clusters") + } + + memberClusterClients, err := multicluster.CreateMemberClusterClients(memberClustersNames) + if err != nil { + log.Fatal(err) + } + + // Add the cluster object to the manager corresponding to each member clusters. + for k, v := range memberClusterClients { + var cluster runtime_cluster.Cluster + // if length of namespaces is 1 (one particular namespace or * namespace) we can use the namespace in options + // but if we are watching a subset of namespaces we need to initialize the cache with specific namespaces only + if len(namespacesToWatch) == 1 { + cluster, err = runtime_cluster.New(v, func(options *runtime_cluster.Options) { + if namespacesToWatch[0] != "" { + options.Namespace = kubeConfig.GetMemberClusterNamespace() + } + }) + if err != nil { + // don't panic here but rather log the error, for example, error might happen when one of the cluster is + // unreachable, we would still like the operator to continue reconciliation on the other clusters. + log.Errorf("Failed to initialize client for cluster: %s, err: %s", k, err) + continue + } + } else if len(namespacesToWatch) > 1 { + log.Infof("Building member cluster cache for multiple namespaces: %v", namespacesToWatch) + cluster, err = runtime_cluster.New(v, func(options *runtime_cluster.Options) { + options.NewCache = cache.MultiNamespacedCacheBuilder(namespacesToWatch) + }) + if err != nil { + log.Errorf("Failed to initialize client for cluster: %s, err: %s", k, err) + continue + } + } + + log.Infof("Adding cluster %s to cluster map.", k) + memberClusterObjectsMap[k] = cluster + if err = mgr.Add(cluster); err != nil { + log.Fatal(err) + } + } + } + + // Setup all Controllers + var registeredCRDs []string + if registeredCRDs, err = controllers.AddToManager(mgr, crdsToWatch, memberClusterObjectsMap); err != nil { + log.Fatal(err) + } + + for _, r := range registeredCRDs { + log.Infof("Registered CRD: %s", r) + } + + log.Info("Starting the Cmd.") + + // Start the Manager + if err := mgr.Start(signals.SetupSignalHandler()); err != nil { + log.Fatal(err) + } +} + +// getMemberClusters retrieves the member cluster from the configmap util.MemberListConfigMapName +func getMemberClusters(cfg *rest.Config) ([]string, error) { + c, err := client.New(cfg, client.Options{}) + if err != nil { + panic(err) + } + + m := corev1.ConfigMap{} + err = c.Get(context.Background(), types.NamespacedName{Name: util.MemberListConfigMapName, Namespace: env.ReadOrPanic(util.CurrentNamespace)}, &m) + if err != nil { + return nil, err + } + + var members []string + for member := range m.Data { + members = append(members, member) + } + + return members, nil +} + +func isInLocalMode() bool { + return operatorEnvironments[1] == env.ReadOrPanic(util.OmOperatorEnv) +} + +// setupWebhook sets up the validation webhook for MongoDB resources in order +// to give people early warning when their MongoDB resources are wrong. +func setupWebhook(mgr manager.Manager, cfg *rest.Config, log *zap.SugaredLogger, multiClusterMode bool) { + // set webhook port — 1993 is chosen as Ben's birthday + webhookPort := env.ReadIntOrDefault(mdbWebHookPortEnvName, 1993) + mgr.GetWebhookServer().Port = webhookPort + if isInLocalMode() { + mgr.GetWebhookServer().Host = "127.0.0.1" + } + + // this is the default directory on Linux but setting it explicitly helps + // with cross-platform compatibility, specifically local development on MacOS + certDir := "/tmp/k8s-webhook-server/serving-certs/" + mgr.GetWebhookServer().CertDir = certDir + + // create a kubernetes client that the webhook server can use. We can't reuse + // the one from the manager as it is not initialised yet. + webhookClient, err := client.New(cfg, client.Options{}) + if err != nil { + panic(err) + } + + // webhookServiceLocation is the name and namespace of the webhook service + // that will be created. + webhookServiceLocation := types.NamespacedName{ + Name: "operator-webhook", + Namespace: env.ReadOrPanic(util.CurrentNamespace), + } + if err := webhook.Setup(webhookClient, webhookServiceLocation, certDir, webhookPort, multiClusterMode); err != nil { + log.Warnw("could not set up webhook", "error", err) + } + log.Info("setup webhook successfully") +} + +func initializeEnvironment() { + omOperatorEnv := os.Getenv(util.OmOperatorEnv) + configuredEnv := omOperatorEnv + if !validateEnv(omOperatorEnv) { + omOperatorEnv = operatorEnvironments[0] + } + + initLogger(omOperatorEnv) + + if configuredEnv != omOperatorEnv { + log.Infof("Configured environment %s, not recognized. Must be one of %v", configuredEnv, operatorEnvironments) + log.Infof("Using default environment, %s, instead", operatorEnvironments[0]) + } + + initEnvVariables() + + log.Infof("Operator environment: %s", omOperatorEnv) + log.Infof("Operator version: %s", util.OperatorVersion) + log.Infof("Go Version: %s", localruntime.Version()) + log.Infof("Go OS/Arch: %s/%s", localruntime.GOOS, localruntime.GOARCH) + + printableEnvPrefixes := []string{ + "BACKUP_WAIT_", + "POD_WAIT_", + "OPERATOR_ENV", + "WATCH_NAMESPACE", + "MANAGED_SECURITY_CONTEXT", + "IMAGE_PULL_SECRETS", + "MONGODB_ENTERPRISE_", + "OPS_MANAGER_", + "KUBERNETES_", + "AGENT_IMAGE", + "MONGODB_", + "INIT_", + } + + // Only env variables with one of these prefixes will be printed + env.PrintWithPrefix(printableEnvPrefixes) +} + +// initEnvVariables is the central place in application to initialize default configuration for the application (using +// env variables). Having the central place to manage defaults increases manageability and transparency of the application +// Method initializes variables only in case they are not specified already. +func initEnvVariables() { + env.EnsureVar(util.BackupDisableWaitSecondsEnv, util.DefaultBackupDisableWaitSeconds) + env.EnsureVar(util.BackupDisableWaitRetriesEnv, util.DefaultBackupDisableWaitRetries) + env.EnsureVar(util.OpsManagerMonitorAppDB, strconv.FormatBool(util.OpsManagerMonitorAppDBDefault)) +} + +func validateEnv(env string) bool { + return stringutil.Contains(operatorEnvironments[:], env) +} + +func initLogger(env string) { + var logger *zap.Logger + var e error + + switch env { + case "prod": + logger, e = zap.NewProduction() + case "dev", "local": + // Overriding the default stacktrace behavior - have them only for errors but not for warnings + logger, e = zap.NewDevelopment(zap.AddStacktrace(zap.ErrorLevel)) + } + + if e != nil { + fmt.Println("Failed to create logger, will use the default one") + fmt.Println(e) + } + zap.ReplaceGlobals(logger) + log = zap.S() +} diff --git a/multi_cluster/cluster.yaml b/multi_cluster/cluster.yaml new file mode 100644 index 000000000..51a7ac30e --- /dev/null +++ b/multi_cluster/cluster.yaml @@ -0,0 +1,106 @@ +# This configuration allows us to create a K8s cluster in AWS, with sharing VPC +# and connectivity b/w the different clusters. it makes the pod IPs routable accross clusters, which is needed for +# Istio setup with shared network. +# To use the YAML spec below: +# i. Make sure you have an exisiting cluster running in an isolated VPC. +# ii. Edit the "" variable with the clustername you want in this spec. +# iii. Set the "" of the cluster previosuly setup. +# iv. Provive a non-overlapping CIDR for this new cluster +# v. kops create -f cluster.yaml +# verify by deploying a pod in cluster1 and make sure it's curl"able" from cluster2 +apiVersion: kops.k8s.io/v1alpha2 +kind: Cluster +metadata: + creationTimestamp: null + name: +spec: + api: + dns: {} + authorization: + rbac: {} + channel: stable + cloudProvider: aws + configBase: s3://kube-om-state-store/ + containerRuntime: docker + etcdClusters: + - cpuRequest: 200m + etcdMembers: + - instanceGroup: master- + name: a + memoryRequest: 100Mi + name: main + - cpuRequest: 100m + etcdMembers: + - instanceGroup: master- + name: a + memoryRequest: 100Mi + name: events + iam: + allowContainerRegistry: true + legacy: false + kubelet: + anonymousAuth: false + kubernetesApiAccess: + - 0.0.0.0/0 + kubernetesVersion: 1.20.10 + masterPublicName: api. + networkID: + # configure the CNI, default "kubenet" CNI which KOPS uses won't work + networking: + amazonvpc: {} + # select a non overlapping CIDR block, separate from the one previously deployed, else + # the API server will get confused seeing the same IP coming from two different clusters + nonMasqueradeCIDR: 172.20.64.0/19 + sshAccess: + - 0.0.0.0/0 + subnets: + - cidr: + name: + type: Public + zone: + topology: + dns: + type: Public + masters: public + nodes: public +--- +# master instance group +apiVersion: kops.k8s.io/v1alpha2 +kind: InstanceGroup +metadata: + creationTimestamp: null + labels: + kops.k8s.io/cluster: + name: master- +spec: + image: 099720109477/ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-20210315 + machineType: + maxSize: 1 + minSize: 1 + nodeLabels: + kops.k8s.io/instancegroup: master- + role: Master + rootVolumeSize: 16 + subnets: + - + +--- +# node instance group +apiVersion: kops.k8s.io/v1alpha2 +kind: InstanceGroup +metadata: + creationTimestamp: null + labels: + kops.k8s.io/cluster: + name: nodes- +spec: + image: 099720109477/ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-20210315 + machineType: + maxSize: 4 + minSize: 4 + nodeLabels: + kops.k8s.io/instancegroup: nodes- + role: Node + rootVolumeSize: 40 + subnets: + - diff --git a/multi_cluster/create_security_groups.py b/multi_cluster/create_security_groups.py new file mode 100755 index 000000000..3236bf027 --- /dev/null +++ b/multi_cluster/create_security_groups.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 + +from typing import List + +import boto3 +import sys + + +def get_instance_groups_for_cluster(cluster_name: str) -> List[str]: + """ + :param cluster_name: name of the cluster + :return: list of instance groups associated with that cluster + """ + return [f"nodes.{cluster_name}", f"masters.{cluster_name}"] + + +def get_other_instance_groups_for_cluster( + cluster_name: str, all_clusters: List[str] +) -> List[str]: + """ + :param cluster_name: the name of the cluster + :param all_clusters: a list of all clusters + :return: a list of instance group names that need to have rules added for + """ + other_instance_groups = [] + for cluster in all_clusters: + if cluster == cluster_name: + continue + other_instance_groups.extend(get_instance_groups_for_cluster(cluster)) + return other_instance_groups + + +def get_all_instance_groups(cluster_names: List[str]) -> List[str]: + """ + :param cluster_names: list of all cluster names. + :return: list of all instance group names. + """ + all_instance_groups = [] + for cluster in cluster_names: + all_instance_groups.extend(get_instance_groups_for_cluster(cluster)) + return all_instance_groups + + +def get_security_group_by_name(security_groups, name: str): + """ + :param security_groups: list of all security group objects. + :param name: name of the desired security group. + :return: + """ + return next(iter([sg for sg in security_groups if sg.group_name == name])) + + +def _add_all_traffic_security_group_rule(sg0, sg1, vpc_id): + """ + :param sg0: security group that the ingres will be added to. + :param sg1: security group that the inbound rule will be added to. + :param vpc_id: vpc id of both security groups. + """ + sg0.authorize_ingress( + IpPermissions=[ + { + "IpRanges": [{"CidrIp": "0.0.0.0/0"}], + "IpProtocol": "-1", + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [ + {"VpcId": vpc_id, "GroupId": sg1.id, "UserId": "268558157000"} + ], + }, + ] + ) + + +def main( + region: str, + vpc_id: str, + cluster_names: List[str], +): + """ + This script creates inbound rules allowing all traffic between all instances groups + associated with all of the clusters provided. + + + :param region: the aws region. + :param vpc_id: the vpc id associated with the clusters. + :param cluster_names: the names of the clusters. + :return: None + """ + ec2 = boto3.resource("ec2", region_name=region) + + security_groups = ec2.security_groups.all() + for cluster in cluster_names: + cluster_instance_groups = get_instance_groups_for_cluster(cluster) + other_instance_group_names = get_other_instance_groups_for_cluster( + cluster, cluster_names + ) + + for instance_group in cluster_instance_groups: + instance_group_sg = get_security_group_by_name( + security_groups, instance_group + ) + for other_instance_group in other_instance_group_names: + other_instance_group_sg = get_security_group_by_name( + security_groups, other_instance_group + ) + print( + f"adding rule for {instance_group_sg.group_name} to {other_instance_group_sg.group_name}" + ) + try: + _add_all_traffic_security_group_rule( + instance_group_sg, other_instance_group_sg, vpc_id + ) + except Exception as e: + print(e) + + +if __name__ == "__main__": + if len(sys.argv) != 4: + raise ValueError( + "Usage: create_security_groups.py " + ) + + region = sys.argv[1] + vpc_id = sys.argv[2] + cluster_names = sys.argv[3].split(",") + + main(region, vpc_id, cluster_names) diff --git a/multi_cluster/setup_multi_cluster_environment.sh b/multi_cluster/setup_multi_cluster_environment.sh new file mode 100755 index 000000000..c4c4db37c --- /dev/null +++ b/multi_cluster/setup_multi_cluster_environment.sh @@ -0,0 +1,58 @@ +#!/bin/bash + +aws_region="eu-west-2" +region="${aws_region}a" +cluster_one_name="e2e.operator.mongokubernetes.com" +cluster_two_name="e2e.cluster1.mongokubernetes.com" +cluster_three_name="e2e.cluster2.mongokubernetes.com" +master_size="m5.large" +node_size="t2.medium" + +# forward slash needs to be escaped for sed +# these are all non overlapping ranges. +cluster_one_cidr="172.20.32.0\/19" +cluster_two_cidr="172.20.64.0\/19" +cluster_three_cidr="172.20.0.0\/19" + +if ! kops validate cluster "${cluster_one_name}"; then + echo "Kops cluster \"${cluster_one_name}\" doesn't exist" + sed -e "s//${cluster_one_name}/g" -e "s//${region}/g" -e "s//${cluster_one_cidr}/g" -e '//d' -e "s//${master_size}/g" -e "s//${node_size}/g" < cluster.yaml | kops create -f - + kops create secret --name ${cluster_one_name} sshpublickey admin -i ~/.ssh/id_rsa.pub + kops update cluster --name ${cluster_one_name} --yes + echo "Waiting until kops cluster gets ready..." + kops export kubecfg "${cluster_one_name}" --admin=87600h + kops validate cluster "${cluster_one_name}" --wait 20m +fi +kops export kubecfg "${cluster_one_name}" --admin=87600h + + +VPC_ID="$(aws ec2 describe-vpcs --region ${aws_region} --filters Name=tag:Name,Values=${cluster_one_name} | jq -r .Vpcs[].VpcId)" +echo "VPC ID is ${VPC_ID}" + +if ! kops validate cluster "${cluster_two_name}"; then + echo "Kops cluster \"${cluster_two_name}\" doesn't exist" + sed -e "s//${cluster_two_name}/g" -e "s//${region}/g" -e "s//${cluster_two_cidr}/g" -e "s//${VPC_ID}/g" -e "s//${master_size}/g" -e "s//${node_size}/g" < cluster.yaml | kops create -f - + kops create secret --name ${cluster_two_name} sshpublickey admin -i ~/.ssh/id_rsa.pub + kops update cluster --name ${cluster_two_name} --yes + echo "Waiting until kops cluster ${cluster_two_name} gets ready..." + + kops export kubecfg "${cluster_two_name}" --admin=87600h + kops validate cluster "${cluster_two_name}" --wait 20m +fi +kops export kubecfg "${cluster_two_name}" --admin=87600h + + +if ! kops validate cluster "${cluster_three_name}"; then + echo "Kops cluster \"${cluster_three_name}\" doesn't exist" + sed -e "s//${cluster_three_name}/g" -e "s//${region}/g" -e "s//${cluster_three_cidr}/g" -e "s//${VPC_ID}/g" -e "s//${master_size}/g" -e "s//${node_size}/g" < cluster.yaml | kops create -f - + kops create secret --name ${cluster_three_name} sshpublickey admin -i ~/.ssh/id_rsa.pub + kops update cluster --name ${cluster_three_name} --yes + echo "Waiting until kops cluster ${cluster_three_name} gets ready..." + + kops export kubecfg "${cluster_three_name}" --admin=87600h + kops validate cluster "${cluster_three_name}" --wait 20m +fi +kops export kubecfg "${cluster_three_name}" --admin=87600h + + +./create_security_groups.py "${aws_region}" "${VPC_ID}" "${cluster_one_name},${cluster_two_name},${cluster_three_name}" diff --git a/multi_cluster/tools/README.md b/multi_cluster/tools/README.md new file mode 100644 index 000000000..b9e09744d --- /dev/null +++ b/multi_cluster/tools/README.md @@ -0,0 +1,19 @@ +### Installing Istio in e2e clusters + +The script is intended to install Istio in the multi E2E clusters that we have currently deployed. + +Steps to run the script and verify it: + +* Install the istioctl binary: + `curl -sL https://istio.io/downloadIstioctl | ISTIO_VERSION=1.9.1 sh -` + `export PATH=$PATH:$HOME/.istioctl/bin` + +* Export cluster variables: + `export CTX_CLUSTER1=e2e.cluster1.mongokubernetes.com` + + `export CTX_CLUSTER2=e2e.cluster2.mongokubernetes.com ` + + +* Run the script : `sh ./install_istio.sh` + +* [Verify the Istio installation](https://istio.io/latest/docs/setup/install/multicluster/verify/) \ No newline at end of file diff --git a/multi_cluster/tools/download_istio.sh b/multi_cluster/tools/download_istio.sh new file mode 100755 index 000000000..ad9930c49 --- /dev/null +++ b/multi_cluster/tools/download_istio.sh @@ -0,0 +1,10 @@ +#!/bin/bash +set -Eeou pipefail + +export VERSION=${VERSION:-1.16.1} + +echo "Downloading istio" +ISTIO_SCRIPT_CHECKSUM="254c6bd6aa5b8ac8c552561c84d8e9b3a101d9e613e2a8edd6db1f19c1871dbf" +[ ! -d "istio-${VERSION}" ] && curl -O https://raw.githubusercontent.com/istio/istio/d710dfc2f95adb9399e1656165fa5ac22f6e1a16/release/downloadIstioCandidate.sh +echo "${ISTIO_SCRIPT_CHECKSUM} downloadIstioCandidate.sh" | sha256sum --check +[ ! -d "istio-${VERSION}" ] && ISTIO_VERSION=${VERSION} sh downloadIstioCandidate.sh diff --git a/multi_cluster/tools/install_istio.sh b/multi_cluster/tools/install_istio.sh new file mode 100755 index 000000000..76a9d65a0 --- /dev/null +++ b/multi_cluster/tools/install_istio.sh @@ -0,0 +1,180 @@ +#!/bin/bash + +set -eux + +export CTX_CLUSTER1=${CTX_CLUSTER1:-e2e.cluster1.mongokubernetes.com} +export CTX_CLUSTER2=${CTX_CLUSTER2:-e2e.cluster2.mongokubernetes.com} +export CTX_CLUSTER3=${CTX_CLUSTER3:-e2e.cluster3.mongokubernetes.com} +export VERSION=${VERSION:-1.12.8} + +IS_KIND="false" +if [[ $CTX_CLUSTER1 = kind* ]]; then + IS_KIND="true" +fi + +source multi_cluster/tools/download_istio.sh || true + +# +cd istio-${VERSION} +## perform cleanup prior to install +bin/istioctl x uninstall --context="${CTX_CLUSTER1}" --purge --skip-confirmation & +bin/istioctl x uninstall --context="${CTX_CLUSTER2}" --purge --skip-confirmation & +bin/istioctl x uninstall --context="${CTX_CLUSTER3}" --purge --skip-confirmation & +wait + +rm -rf certs +mkdir -p certs +pushd certs + +# create root trust for the clusters +make -f ../tools/certs/Makefile.selfsigned.mk "root-ca" +# FIXME: I'm not sure why, but Istio's makefiles seem to fail on my Mac when generating those certs. +# The funny thing is that they are generated fine once I rerun the targets. +# This probably requires a bit more investigation or upgrading Istio to the latest version. +make -f ../tools/certs/Makefile.selfsigned.mk "${CTX_CLUSTER1}-cacerts" || make -f ../tools/certs/Makefile.selfsigned.mk "${CTX_CLUSTER1}-cacerts" +make -f ../tools/certs/Makefile.selfsigned.mk "${CTX_CLUSTER2}-cacerts" || make -f ../tools/certs/Makefile.selfsigned.mk "${CTX_CLUSTER2}-cacerts" +make -f ../tools/certs/Makefile.selfsigned.mk "${CTX_CLUSTER3}-cacerts" || make -f ../tools/certs/Makefile.selfsigned.mk "${CTX_CLUSTER3}-cacerts" + +# create cluster secret objects with the certs and keys +kubectl --context="${CTX_CLUSTER1}" delete ns istio-system || true +kubectl --context="${CTX_CLUSTER1}" create ns istio-system +kubectl --context="${CTX_CLUSTER1}" create secret generic cacerts -n istio-system \ + --from-file=${CTX_CLUSTER1}/ca-cert.pem \ + --from-file=${CTX_CLUSTER1}/ca-key.pem \ + --from-file=${CTX_CLUSTER1}/root-cert.pem \ + --from-file=${CTX_CLUSTER1}/cert-chain.pem + +kubectl --context="${CTX_CLUSTER2}" delete ns istio-system || true +kubectl --context="${CTX_CLUSTER2}" create ns istio-system +kubectl --context="${CTX_CLUSTER2}" create secret generic cacerts -n istio-system \ + --from-file=${CTX_CLUSTER2}/ca-cert.pem \ + --from-file=${CTX_CLUSTER2}/ca-key.pem \ + --from-file=${CTX_CLUSTER2}/root-cert.pem \ + --from-file=${CTX_CLUSTER2}/cert-chain.pem + +kubectl --context="${CTX_CLUSTER3}" delete ns istio-system || true +kubectl --context="${CTX_CLUSTER3}" create ns istio-system +kubectl --context="${CTX_CLUSTER3}" create secret generic cacerts -n istio-system \ + --from-file=${CTX_CLUSTER3}/ca-cert.pem \ + --from-file=${CTX_CLUSTER3}/ca-key.pem \ + --from-file=${CTX_CLUSTER3}/root-cert.pem \ + --from-file=${CTX_CLUSTER3}/cert-chain.pem +popd + +# install IstioOperator in clusters +cat <cluster1.yaml +apiVersion: install.istio.io/v1alpha1 +kind: IstioOperator +spec: + tag: ${VERSION} + meshConfig: + defaultConfig: + proxyMetadata: + ISTIO_META_DNS_AUTO_ALLOCATE: "true" + ISTIO_META_DNS_CAPTURE: "true" + values: + global: + meshID: mesh1 + multiCluster: + clusterName: cluster1 + network: network1 +EOF + +bin/istioctl install --context="${CTX_CLUSTER1}" -f cluster1.yaml -y & + +cat <cluster2.yaml +apiVersion: install.istio.io/v1alpha1 +kind: IstioOperator +spec: + tag: ${VERSION} + meshConfig: + defaultConfig: + proxyMetadata: + ISTIO_META_DNS_AUTO_ALLOCATE: "true" + ISTIO_META_DNS_CAPTURE: "true" + values: + global: + meshID: mesh1 + multiCluster: + clusterName: cluster2 + network: network1 +EOF + +bin/istioctl install --context="${CTX_CLUSTER2}" -f cluster2.yaml -y & + +cat <cluster3.yaml +apiVersion: install.istio.io/v1alpha1 +kind: IstioOperator +spec: + tag: ${VERSION} + meshConfig: + defaultConfig: + proxyMetadata: + ISTIO_META_DNS_AUTO_ALLOCATE: "true" + ISTIO_META_DNS_CAPTURE: "true" + values: + global: + meshID: mesh1 + multiCluster: + clusterName: cluster3 + network: network1 +EOF + +bin/istioctl install --context="${CTX_CLUSTER3}" -f cluster3.yaml -y & + +wait + +CLUSTER_1_ADDITIONAL_OPTS="" +CLUSTER_2_ADDITIONAL_OPTS="" +CLUSTER_3_ADDITIONAL_OPTS="" +if [[ $IS_KIND == "true" ]]; then + CLUSTER_1_ADDITIONAL_OPTS="--server https://$(kubectl --context=${CTX_CLUSTER1} get node e2e-cluster-1-control-plane -o=jsonpath='{.status.addresses[?(@.type=="InternalIP")].address}'):6443" + CLUSTER_2_ADDITIONAL_OPTS="--server https://$(kubectl --context=${CTX_CLUSTER2} get node e2e-cluster-2-control-plane -o=jsonpath='{.status.addresses[?(@.type=="InternalIP")].address}'):6443" + CLUSTER_3_ADDITIONAL_OPTS="--server https://$(kubectl --context=${CTX_CLUSTER3} get node e2e-cluster-3-control-plane -o=jsonpath='{.status.addresses[?(@.type=="InternalIP")].address}'):6443" +fi + +# enable endpoint discovery +bin/istioctl x create-remote-secret \ + --context="${CTX_CLUSTER1}" \ + -n istio-system \ + --name=cluster1 ${CLUSTER_1_ADDITIONAL_OPTS} | + kubectl apply -f - --context="${CTX_CLUSTER2}" + +bin/istioctl x create-remote-secret \ + --context="${CTX_CLUSTER1}" \ + -n istio-system \ + --name=cluster1 ${CLUSTER_1_ADDITIONAL_OPTS} | + kubectl apply -f - --context="${CTX_CLUSTER3}" + +bin/istioctl x create-remote-secret \ + --context="${CTX_CLUSTER2}" \ + -n istio-system \ + --name=cluster2 ${CLUSTER_2_ADDITIONAL_OPTS} | + kubectl apply -f - --context="${CTX_CLUSTER1}" + +bin/istioctl x create-remote-secret \ + --context="${CTX_CLUSTER2}" \ + -n istio-system \ + --name=cluster2 ${CLUSTER_2_ADDITIONAL_OPTS} | + kubectl apply -f - --context="${CTX_CLUSTER3}" + +bin/istioctl x create-remote-secret \ + --context="${CTX_CLUSTER3}" \ + -n istio-system \ + --name=cluster3 ${CLUSTER_3_ADDITIONAL_OPTS} | + kubectl apply -f - --context="${CTX_CLUSTER1}" + +bin/istioctl x create-remote-secret \ + --context="${CTX_CLUSTER3}" \ + -n istio-system \ + --name=cluster3 ${CLUSTER_3_ADDITIONAL_OPTS} | + kubectl apply -f - --context="${CTX_CLUSTER2}" +# disable namespace injection explicitly for istio-system namespace +kubectl --context="${CTX_CLUSTER1}" label namespace istio-system istio-injection=disabled +kubectl --context="${CTX_CLUSTER2}" label namespace istio-system istio-injection=disabled +kubectl --context="${CTX_CLUSTER3}" label namespace istio-system istio-injection=disabled + +# Skipping the cleanup for now. Otherwise we won't have the tools to diagnose issues +#cd .. +#rm -r istio-${VERSION} +#rm -f cluster1.yaml cluster2.yaml cluster3.yaml diff --git a/multi_cluster/tools/install_istio_central.sh b/multi_cluster/tools/install_istio_central.sh new file mode 100755 index 000000000..8e97fe7a3 --- /dev/null +++ b/multi_cluster/tools/install_istio_central.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -eux + +export VERSION=${VERSION:-1.14.2} + +export CTX_CLUSTER=${CTX_CLUSTER:-e2e.operator.mongokubernetes.com} + +source multi_cluster/tools/download_istio.sh || true +cd istio-${VERSION} + +bin/istioctl x uninstall --context="${CTX_CLUSTER}" --purge --skip-confirmation +bin/istioctl install --context="${CTX_CLUSTER}" --set profile=default --set meshConfig.outboundTrafficPolicy.mode=REGISTRY_ONLY --skip-confirmation diff --git a/pipeline.py b/pipeline.py new file mode 100755 index 000000000..0e5d579ad --- /dev/null +++ b/pipeline.py @@ -0,0 +1,782 @@ +#!/usr/bin/env python3 + +"""This pipeline script knows about the details of our Docker images +and where to fetch and calculate parameters. It uses Sonar.py +to produce the final images.""" + +import argparse +import copy +import json +import logging +import os +import re + +import semver +import shutil +import subprocess +import sys +import tarfile +from dataclasses import dataclass +from datetime import datetime, timedelta +from distutils.dir_util import copy_tree +from distutils.version import StrictVersion +from typing import Dict, List, Optional, Tuple, Union + +import requests +from sonar.sonar import process_image + +import docker + +LOGLEVEL = os.environ.get("LOGLEVEL", "INFO").upper() +logger = logging.getLogger("pipeline") +logger.setLevel(LOGLEVEL) + +skippable_tags = frozenset(["ubi", "ubuntu"]) + +DEFAULT_IMAGE_TYPE = "ubuntu" +DEFAULT_NAMESPACE = "default" + + +@dataclass +class BuildConfiguration: + image_type: str + base_repository: str + namespace: str + + include_tags: Optional[List[str]] + skip_tags: Optional[List[str]] + + builder: str = "docker" + parallel: bool = False + + pipeline: bool = True + debug: bool = True + + def build_args(self, args: Optional[Dict[str, str]] = None) -> Dict[str, str]: + if args is None: + args = {} + args = args.copy() + + args["registry"] = self.base_repository + + return args + + def get_skip_tags(self) -> Optional[Dict[str, str]]: + return make_list_of_str(self.skip_tags) + + def get_include_tags(self) -> Optional[Dict[str, str]]: + return make_list_of_str(self.include_tags) + + +def make_list_of_str(value: Union[None, str, List[str]]) -> List[str]: + if value is None: + return [] + + if isinstance(value, str): + return [e.strip() for e in value.split(",")] + + return value + + +def build_configuration_from_context_file(filename: str) -> Dict[str, str]: + config = {} + with open(filename) as fd: + for line in fd.readlines(): + if line.startswith("#") or line.strip() == "": + continue + + key, value = line.split("=") + key = key.replace("export ", "").lower() + value = value.strip().replace('"', "") + config[key] = value + + # calculates skip_tags from image_type in local mode + config["skip_tags"] = list(skippable_tags - {config["image_type"]}) + + # explicitely skipping release tags locally + config["skip_tags"].append("release") + + return config + + +def build_configuration_from_env() -> Dict[str, str]: + """Builds a running configuration by reading values from environment. + This is to be used in Evergreen environment. + """ + + # The `base_repo_url` is suffixed with `/dev` because in Evergreen that + # would replace the `username` we use locally. + return { + "image_type": os.environ.get("distro"), + "base_repo_url": os.environ["registry"] + "/dev", + "include_tags": os.environ.get("include_tags"), + "skip_tags": os.environ.get("skip_tags"), + } + + +def operator_build_configuration( + builder: str, parallel: bool, debug: bool +) -> BuildConfiguration: + default_config_location = os.path.expanduser("~/.operator-dev/context.env") + context_file = os.environ.get( + "OPERATOR_BUILD_CONFIGURATION", default_config_location + ) + + if os.path.exists(context_file): + context = build_configuration_from_context_file(context_file) + else: + context = build_configuration_from_env() + + return BuildConfiguration( + image_type=context.get("image_type", DEFAULT_IMAGE_TYPE), + base_repository=context.get("base_repo_url", ""), + namespace=context.get("namespace", DEFAULT_NAMESPACE), + skip_tags=context.get("skip_tags"), + include_tags=context.get("include_tags"), + builder=builder, + parallel=parallel, + debug=debug, + ) + + +def should_pin_at() -> Optional[Tuple[str, str]]: + """Gets the value of the pin_tag_at to tag the images with. + + Returns its value splited on :. + """ + # We need to return something so `partition` does not raise + # AttributeError + pinned = os.environ.get("pin_tag_at", "-") + hour, _, minute = pinned.partition(":") + + return hour, minute + + +def build_id() -> str: + """Returns the current UTC time in ISO8601 date format. + + If running in Evergreen and `created_at` expansion is defined, use the + datetime defined in that variable instead. + + It is possible to pin this time at midnight (00:00) for periodic builds. If + running a manual build, then the Evergreen `pin_tag_at` variable needs to be + set to the empty string, in which case, the image tag suffix will correspond + to the current timestamp. + + """ + + date = datetime.utcnow() + try: + created_at = os.environ["created_at"] + date = datetime.strptime(created_at, "%y_%m_%d_%H_%M_%S") + except KeyError: + pass + + hour, minute = should_pin_at() + if hour and minute: + date = date.replace(hour=int(hour), minute=int(minute), second=0) + + return date.strftime("%Y%m%dT%H%M%SZ") + + +def get_release() -> Dict[str, str]: + return json.load(open("release.json")) + + +def get_git_release_tag() -> str: + release_env_var = os.getenv("triggered_by_git_tag") + + if release_env_var is not None: + return release_env_var + + output = subprocess.check_output( + ["git", "describe", "--tags"], + ) + output = output.decode("utf-8") + return output.strip() + + +def copy_into_container(client, src, dst): + """Copies a local file into a running container.""" + + os.chdir(os.path.dirname(src)) + srcname = os.path.basename(src) + with tarfile.open(src + ".tar", mode="w") as tar: + tar.add(srcname) + + name, dst = dst.split(":") + container = client.containers.get(name) + + with open(src + ".tar", "rb") as fd: + container.put_archive(os.path.dirname(dst), fd.read()) + + +def sonar_build_image( + image_name: str, + build_configuration: BuildConfiguration, + args: Dict[str, str] = None, + inventory="inventory.yaml", +): + """Calls sonar to build `image_name` with arguments defined in `args`.""" + build_options = { + # Will continue building an image if it finds an error. See next comment. + "continue_on_errors": True, + # But will still fail after all the tasks have completed + "fail_on_errors": True, + "pipeline": build_configuration.pipeline, + } + process_image( + image_name, + skip_tags=build_configuration.get_skip_tags(), + include_tags=build_configuration.get_include_tags(), + build_args=build_configuration.build_args(args), + inventory=inventory, + build_options=build_options, + ) + + +def build_tests_image(build_configuration: BuildConfiguration): + """ + Builds image used to run tests. + """ + image_name = "test" + + # helm directory needs to be copied over to the tests docker context. + helm_src = "helm_chart" + helm_dest = "docker/mongodb-enterprise-tests/helm_chart" + + shutil.rmtree(helm_dest, ignore_errors=True) + copy_tree(helm_src, helm_dest) + + sonar_build_image(image_name, build_configuration, {}, "inventories/test.yaml") + + +def build_operator_image(build_configuration: BuildConfiguration): + """Calculates arguments required to build the operator image, and starts the build process.""" + image_name = "operator" + + # In evergreen we can pass test_suffix env to publish the operator to a quay + # repostory with a given suffix. + test_suffix = os.environ.get("test_suffix", "") + + log_automation_config_diff = os.environ.get("LOG_AUTOMATION_CONFIG_DIFF", "false") + args = dict( + release_version=get_git_release_tag(), + log_automation_config_diff=log_automation_config_diff, + test_suffix=test_suffix, + debug=build_configuration.debug, + ) + + sonar_build_image(image_name, build_configuration, args) + + +def build_database_image(build_configuration: BuildConfiguration): + """ + Builds a new database image. + """ + image_name = "database" + release = get_release() + + version = release["databaseImageVersion"] + + args = dict( + version=version, + ) + + sonar_build_image( + image_name, build_configuration, args, "inventories/database.yaml" + ) + + +def build_operator_image_patch(build_configuration: BuildConfiguration): + """This function builds the operator locally and pushed into an existing + Docker image. This is the fastest way I could image we can do this.""" + + client = docker.from_env() + # image that we know is where we build operator. + image_repo = ( + build_configuration.base_repository + + "/" + + build_configuration.image_type + + "/mongodb-enterprise-operator" + ) + image_tag = "latest" + repo_tag = image_repo + ":" + image_tag + + logger.debug("Pulling image:", repo_tag) + try: + image = client.images.get(repo_tag) + except docker.errors.ImageNotFound: + logger.debug("Operator image does not exist locally. Building it now") + build_operator_image(build_configuration) + return + + logger.debug("Done") + too_old = datetime.now() - timedelta(hours=3) + image_timestamp = datetime.fromtimestamp( + image.history()[0]["Created"] + ) # Layer 0 is the latest added layer to this Docker image. [-1] is the FROM layer. + + if image_timestamp < too_old: + logger.info( + "Current operator image is too old, will rebuild it completely first" + ) + build_operator_image(build_configuration) + return + + container_name = "mongodb-enterprise-operator" + operator_binary_location = "/usr/local/bin/mongodb-enterprise-operator" + try: + client.containers.get(container_name).remove() + logger.debug(f"Removed {container_name}") + except docker.errors.NotFound: + pass + + container = client.containers.run( + repo_tag, name=container_name, entrypoint="sh", detach=True + ) + + logger.debug("Building operator with debugging symbols") + subprocess.run(["make", "manager"], check=True, stdout=subprocess.PIPE) + logger.debug("Done building the operator") + + copy_into_container( + client, + os.getcwd() + + "/docker/mongodb-enterprise-operator/content/mongodb-enterprise-operator", + container_name + ":" + operator_binary_location, + ) + + # Commit changes on disk as a tag + container.commit( + repository=image_repo, + tag=image_tag, + ) + # Stop this container so we can use it next time + container.stop() + container.remove() + + logger.info("Pushing operator to {}:{}".format(image_repo, image_tag)) + client.images.push( + repository=image_repo, + tag=image_tag, + ) + + +def get_supported_version_for_image(image: str) -> List[Dict[str, str]]: + return get_release()["supportedImages"][image]["versions"] + + +def get_supported_variants_for_image(image: str) -> List[Dict[str, str]]: + return get_release()["supportedImages"][image]["variants"] + + +def image_config( + image_name: str, + name_prefix: str = "mongodb-enterprise-", + s3_bucket: str = "enterprise-operator-dockerfiles", + ubi_suffix: str = "-ubi", + ubuntu_suffix: str = "", +) -> Tuple[str, Dict[str, str]]: + """Generates configuration for an image suitable to be passed + to Sonar. + + It returns a dictionary with registries and S3 configuration.""" + args = { + "quay_registry": "quay.io/mongodb/{}{}".format(name_prefix, image_name), + "ecr_registry": "268558157000.dkr.ecr.us-east-1.amazonaws.com/images/ubuntu/{}{}".format( + name_prefix, image_name + ), + "ecr_registry_ubi": "268558157000.dkr.ecr.us-east-1.amazonaws.com/images/ubi/{}{}".format( + name_prefix, image_name + ), + "s3_bucket_http": "https://{}.s3.amazonaws.com/dockerfiles/{}{}".format( + s3_bucket, name_prefix, image_name + ), + "ubi_suffix": ubi_suffix, + "ubuntu_suffix": ubuntu_suffix, + } + + return image_name, args + + +def args_for_daily_image(image_name: str) -> Dict[str, str]: + """Returns configuration for an image to be able to be pushed with Sonar. + + This includes the quay_registry and ospid corresponding to RedHat's project id. + """ + image_configs = [ + image_config("appdb"), + image_config("database"), + image_config("init-appdb"), + image_config("init-database"), + image_config("init-ops-manager"), + image_config("operator"), + image_config("ops-manager"), + image_config("mongodb-agent", name_prefix=""), + image_config( + image_name="mongodb-kubernetes-operator", + name_prefix="", + s3_bucket="enterprise-operator-dockerfiles", + # community ubi image does not have a suffix in its name + ubi_suffix="", + # there is no ubuntu version of this image + ubuntu_suffix="", + ), + image_config( + image_name="mongodb-kubernetes-readinessprobe", + ubi_suffix="", + ubuntu_suffix="", + name_prefix="", + s3_bucket="enterprise-operator-dockerfiles", + ), + image_config( + image_name="mongodb-kubernetes-operator-version-upgrade-post-start-hook", + ubi_suffix="", + ubuntu_suffix="", + name_prefix="", + s3_bucket="enterprise-operator-dockerfiles", + ), + ] + + images = {k: v for k, v in image_configs} + return images[image_name] + + +def build_image_daily( + image_name: str, + min_version: str = None, + max_version: str = None, +): + """Builds a daily image.""" + + def inner(build_configuration: BuildConfiguration): + supported_versions = get_supported_version_for_image(image_name) + variants = get_supported_variants_for_image(image_name) + + args = args_for_daily_image(image_name) + args["build_id"] = build_id() + logger.info( + "Supported Versions for {}: {}".format(image_name, supported_versions) + ) + completed_versions = set() + for version in supported_versions: + if ( + min_version is not None + and max_version is not None + and ( + semver.compare(version, min_version) < 0 + or semver.compare(version, max_version) >= 0 + ) + ): + continue + + build_configuration = copy.deepcopy(build_configuration) + if build_configuration.include_tags is None: + build_configuration.include_tags = [] + + build_configuration.include_tags.extend(variants) + + logger.info("Rebuilding {} with variants {}".format(version, variants)) + args["release_version"] = version + if version not in completed_versions: + try: + sonar_build_image( + "image-daily-build", + build_configuration, + args, + inventory="inventories/daily.yaml", + ) + completed_versions.add(version) + except Exception as e: + # Log error and continue + logger.error(e) + + return inner + + +def find_om_in_releases(om_version: str, releases: Dict[str, str]) -> Optional[str]: + """There are a few alternatives out there that allow for json-path or xpath-type + traversal of Json objects in Python, I don't have time to look for one of + them now but I have to do at some point. + """ + for release in releases: + if release["version"] == om_version: + for platform in release["platform"]: + if platform["package_format"] == "deb" and platform["arch"] == "x86_64": + for package in platform["packages"]["links"]: + if package["name"] == "tar.gz": + return package["download_link"] + return None + + +def get_om_releases() -> Dict[str, str]: + """Returns a dictionary representation of the Json document holdin all the OM + releases. + """ + ops_manager_release_archive = "https://info-mongodb-com.s3.amazonaws.com/com-download-center/ops_manager_release_archive.json" + + return requests.get(ops_manager_release_archive).json() + + +def find_om_url(om_version: str) -> str: + """Gets a download URL for a given version of OM.""" + releases = get_om_releases() + + current_release = find_om_in_releases(om_version, releases["currentReleases"]) + if current_release is None: + current_release = find_om_in_releases(om_version, releases["oldReleases"]) + + if current_release is None: + raise ValueError("Ops Manager version {} could not be found".format(om_version)) + + return current_release + + +def build_init_om_image(build_configuration: BuildConfiguration): + image_name = "init-ops-manager" + + release = get_release() + init_om_version = release["initOpsManagerVersion"] + + args = dict(version=init_om_version) + + sonar_build_image(image_name, build_configuration, args, "inventories/init_om.yaml") + + +def build_om_image(build_configuration: BuildConfiguration): + image_name = "ops-manager" + + # Make this a parameter for the Evergreen build + # https://github.com/evergreen-ci/evergreen/wiki/Parameterized-Builds + om_version = os.environ.get("om_version") + if om_version is None: + raise ValueError("`om_version` should be defined.") + + om_download_url = os.environ.get("om_download_url", "") + if om_download_url == "": + om_download_url = find_om_url(om_version) + + args = dict( + om_version=om_version, + om_download_url=om_download_url, + ) + + sonar_build_image(image_name, build_configuration, args, "inventories/om.yaml") + + +def build_init_appdb(build_configuration: BuildConfiguration): + image_name = "init-appdb" + + release = get_release() + + version = release["initAppDbVersion"] + base_url = "https://fastdl.mongodb.org/tools/db/" + + mongodb_tools_url_ubi = "{}{}".format( + base_url, release["mongodbToolsBundle"]["ubi"] + ) + + readiness_probe_version = release["readinessProbeVersion"] + version_upgrade_post_start_hook_version = release[ + "versionUpgradePostStartHookVersion" + ] + + args = dict( + version=version, + mongodb_tools_url_ubi=mongodb_tools_url_ubi, + readiness_probe_version=readiness_probe_version, + version_upgrade_post_start_hook_version=version_upgrade_post_start_hook_version, + is_appdb=True, + ) + + if os.environ.get("readiness_probe"): + logger.info( + "Using readiness_probe source image: %s", os.environ["readiness_probe"] + ) + repo, tag = os.environ["readiness_probe"].split(":") + args["readiness_probe_repo"] = repo + args["readiness_probe_version"] = tag + + sonar_build_image( + image_name, + build_configuration, + args, + "inventories/init_appdb.yaml", + ) + + +def get_builder_function_for_image_name(): + """Returns a dictionary of image names that can be built.""" + + return { + "test": build_tests_image, + "operator": build_operator_image, + "operator-quick": build_operator_image_patch, + "database": build_database_image, + # + # Init images + "init-appdb": build_init_appdb, + "init-database": build_init_database, + "init-ops-manager": build_init_om_image, + # + # Daily builds + "operator-daily": build_image_daily("operator"), + "appdb-daily": build_image_daily("appdb"), + "database-daily": build_image_daily("database"), + "init-appdb-daily": build_image_daily("init-appdb"), + "init-database-daily": build_image_daily("init-database"), + "init-ops-manager-daily": build_image_daily("init-ops-manager"), + "ops-manager-5-daily": build_image_daily( + "ops-manager", min_version="5.0.0", max_version="6.0.0" + ), + "ops-manager-6-daily": build_image_daily( + "ops-manager", min_version="6.0.0", max_version="7.0.0" + ), + # + # Ops Manager image + "ops-manager": build_om_image, + # + # Community images + "mongodb-agent-daily": build_image_daily("mongodb-agent"), + "mongodb-kubernetes-readinessprobe-daily": build_image_daily( + "mongodb-kubernetes-readinessprobe", + ), + "mongodb-kubernetes-operator-version-upgrade-post-start-hook-daily": build_image_daily( + "mongodb-kubernetes-operator-version-upgrade-post-start-hook", + ), + "mongodb-kubernetes-operator-daily": build_image_daily( + "mongodb-kubernetes-operator" + ), + } + + +def build_init_database(build_configuration: BuildConfiguration): + image_name = "init-database" + + release = get_release() + version = release["initDatabaseVersion"] # comes from release.json + + base_url = "https://fastdl.mongodb.org/tools/db/" + + mongodb_tools_url_ubi = "{}{}".format( + base_url, release["mongodbToolsBundle"]["ubi"] + ) + + readiness_probe_version = release["readinessProbeVersion"] + version_upgrade_post_start_hook_version = release[ + "versionUpgradePostStartHookVersion" + ] + + args = dict( + version=version, + mongodb_tools_url_ubi=mongodb_tools_url_ubi, + readiness_probe_version=readiness_probe_version, + version_upgrade_post_start_hook_version=version_upgrade_post_start_hook_version, + is_appdb=False, + ) + + # TODO: + # This is a temporary solution to be able to specify a different readiness_probe image + # at build time. + # If this is set to "" or not set at all, then the default value in + # "inventories/init_database.yaml" will be used. + if os.environ.get("readiness_probe"): + logger.info( + "Using readiness_probe source image: %s", os.environ["readiness_probe"] + ) + repo, tag = os.environ["readiness_probe"].split(":") + args["readiness_probe_repo"] = repo + args["readiness_probe_version"] = tag + + sonar_build_image( + image_name, + build_configuration, + args, + "inventories/init_database.yaml", + ) + + +def build_image(image_name: str, build_configuration: BuildConfiguration): + """Builds one of the supported images by its name.""" + get_builder_function_for_image_name()[image_name](build_configuration) + + +def build_all_images( + images: List[str], builder: str, debug: bool = False, parallel: bool = False +): + """Builds all the images in the `images` list.""" + build_configuration = operator_build_configuration(builder, parallel, debug) + + if parallel: + raise NotImplemented( + "building images in parallel has not been implemented yet." + ) + + for image in images: + build_image(image, build_configuration) + + +def calculate_images_to_build( + images: List[str], include: Optional[List[str]], exclude: Optional[List[str]] +) -> List[str]: + """ + Calculates which images to build based on the `images`, `include` and `exclude` sets. + + >>> calculate_images_to_build(["a", "b"], ["a"], ["b"]) + ... ["a"] + """ + if include is None: + include = [] + if exclude is None: + exclude = [] + + if len(include) == 0 and len(exclude) == 0: + return images + + current_images = images + + images_to_build = [] + for image in include: + if image in current_images: + images_to_build.append(image) + else: + raise ValueError("Image definition {} not found".format(image)) + + for image in exclude: + if image not in images: + raise ValueError("Image definition {} not found".format(image)) + + if len(exclude) > 0: + for image in current_images: + if image not in exclude: + images_to_build.append(image) + + return images_to_build + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--include", action="append") + parser.add_argument("--exclude", action="append") + parser.add_argument("--builder", default="docker", type=str) + parser.add_argument("--list-images", action="store_true") + parser.add_argument("--parallel", action="store_true", default=False) + parser.add_argument("--debug", action="store_true", default=False) + args = parser.parse_args() + + if args.list_images: + print(get_builder_function_for_image_name().keys()) + sys.exit(0) + + images_to_build = calculate_images_to_build( + get_builder_function_for_image_name().keys(), args.include, args.exclude + ) + + build_all_images( + images_to_build, args.builder, debug=args.debug, parallel=args.parallel + ) + + +if __name__ == "__main__": + main() diff --git a/pipeline_test.py b/pipeline_test.py new file mode 100644 index 000000000..aa6fc1a51 --- /dev/null +++ b/pipeline_test.py @@ -0,0 +1,38 @@ +from unittest import mock +from pipeline import operator_build_configuration + + +@mock.patch("builtins.open") +def test_operator_build_configuration(mock_open): + config0 = """ +export IMAGE_TYPE=ubi +export BASE_REPO_URL=somerepo/url +export NAMESPACE="something" +""" + + with mock.patch("builtins.open", mock.mock_open(read_data=config0), create=True): + config = operator_build_configuration("builder", True) + + assert config.image_type == "ubi" + assert config.base_repository == "somerepo/url" + assert config.namespace == "something" + + with mock.patch("builtins.open", mock.mock_open(read_data=""), create=True): + config = operator_build_configuration("builder", True) + + assert config.image_type == "ubuntu" + assert config.base_repository == "" + assert config.namespace == "default" + + +def test_calculate_skip_tags(): + config0 = """ +export IMAGE_TYPE=ubi +export BASE_REPO_URL=somerepo/url +export NAMESPACE="something" +""" + + with mock.patch("builtins.open", mock.mock_open(read_data=config0), create=True): + config = operator_build_configuration("builder", True) + + assert config.skip_tags() == ["ubuntu"] diff --git a/pkg/dns/dns.go b/pkg/dns/dns.go new file mode 100644 index 000000000..6a4d4a4d5 --- /dev/null +++ b/pkg/dns/dns.go @@ -0,0 +1,124 @@ +package dns + +import ( + "fmt" + "strings" + + appsv1 "k8s.io/api/apps/v1" +) + +func GetMultiPodName(mdbmName string, clusterNum, podNum int) string { + return fmt.Sprintf("%s-%d-%d", mdbmName, clusterNum, podNum) +} + +func GetMultiServiceName(mdbmName string, clusterNum, podNum int) string { + return fmt.Sprintf("%s-svc", GetMultiPodName(mdbmName, clusterNum, podNum)) +} + +func GetServiceName(mdbmName string) string { + return fmt.Sprintf("%s-svc", mdbmName) +} + +func GetExternalServiceName(mdbmName string, podNum int) string { + return fmt.Sprintf("%s-%d-svc-external", mdbmName, podNum) +} + +func GetMultiExternalServiceName(mdbmName string, clusterNum, podNum int) string { + return fmt.Sprintf("%s-external", GetMultiServiceName(mdbmName, clusterNum, podNum)) +} + +func GetMultiServiceFQDN(mdbmName, namespace string, clusterNum, podNum int) string { + return fmt.Sprintf("%s.%s.svc.cluster.local", GetMultiServiceName(mdbmName, clusterNum, podNum), namespace) +} + +func GetMultiServiceExternalDomain(mdbmName, externalDomain string, clusterNum, podNum int) string { + return fmt.Sprintf("%s.%s", GetMultiPodName(mdbmName, clusterNum, podNum), externalDomain) +} + +// GetMultiClusterAgentHostnames returns the agent hostnames, which they should be registered in OM in multi-cluster mode. +func GetMultiClusterAgentHostnames(mdbmName, namespace string, clusterNum, members int, externalDomain *string) []string { + hostnames := make([]string, 0) + + for podNum := 0; podNum < members; podNum++ { + var hostname string + if externalDomain != nil { + hostname = GetMultiServiceExternalDomain(mdbmName, *externalDomain, clusterNum, podNum) + } else { + hostname = GetMultiServiceFQDN(mdbmName, namespace, clusterNum, podNum) + } + hostnames = append(hostnames, hostname) + } + + return hostnames +} + +// GetDnsForStatefulSet returns hostnames and names of pods in stateful set "set". This is a preferred way of getting hostnames +// it must be always used if it's possible to read the statefulset from Kubernetes +func GetDnsForStatefulSet(set appsv1.StatefulSet, clusterName string, externalDomain *string) ([]string, []string) { + return GetDnsForStatefulSetReplicasSpecified(set, clusterName, 0, externalDomain) +} + +// GetDnsForStatefulSetReplicasSpecified is similar to GetDnsForStatefulSet but expects the number of replicas to be specified +// (important for scale-down operations to support hostnames for old statefulset) +func GetDnsForStatefulSetReplicasSpecified(set appsv1.StatefulSet, clusterName string, replicas int, externalDomain *string) ([]string, []string) { + if replicas == 0 { + replicas = int(*set.Spec.Replicas) + } + return GetDNSNames(set.Name, set.Spec.ServiceName, set.Namespace, clusterName, replicas, externalDomain) +} + +// GetDnsNames returns hostnames and names of pods in stateful set, it's less preferable than "GetDnsForStatefulSet" and +// should be used only in situations when statefulset doesn't exist any more (the main example is when the mongodb custom +// resource is being deleted - then the dependant statefulsets cannot be read any more as they get into Terminated state) +func GetDNSNames(statefulSetName, service, namespace, clusterName string, replicas int, externalDomain *string) (hostnames, names []string) { + names = make([]string, replicas) + hostnames = make([]string, replicas) + + if externalDomain != nil && len(*externalDomain) > 0 { + for i := 0; i < replicas; i++ { + names[i] = GetPodName(statefulSetName, i) + hostnames[i] = fmt.Sprintf("%s.%s", names[i], *externalDomain) + } + } else { + mName := getDnsTemplateFor(statefulSetName, service, namespace, clusterName) + + for i := 0; i < replicas; i++ { + hostnames[i] = fmt.Sprintf(mName, i) + names[i] = GetPodName(statefulSetName, i) + } + } + + return hostnames, names +} + +// GetServiceFQDN returns the FQDN for the service inside Kubernetes +func GetServiceFQDN(service, namespace, clusterDomain string) string { + if clusterDomain == "" { + clusterDomain = "cluster.local" + } + return fmt.Sprintf("%s.%s.svc.%s", service, namespace, clusterDomain) +} + +// getDnsTemplateFor returns a template-FQDN for a StatefulSet. This +// name will lack one parameter: the index for a given Pod, so the form of +// the returned fqdn will be: +// +// -%d...svc. +// +// The calling code is responsible for interpolating the right index when +// necessary. +// +// TODO: The cluster domain is not known inside the Kubernetes cluster, +// so there is no API to obtain this name from the operator. +// * See: https://github.com/kubernetes/kubernetes/issues/44954 +func getDnsTemplateFor(name, service, namespace, clusterDomain string) string { + if clusterDomain == "" { + clusterDomain = "cluster.local" + } + dnsTemplate := fmt.Sprintf("%s-{}.%s.%s.svc.%s", name, service, namespace, clusterDomain) + return strings.Replace(dnsTemplate, "{}", "%d", 1) +} + +func GetPodName(name string, idx int) string { + return fmt.Sprintf("%s-%d", name, idx) +} diff --git a/pkg/handler/enqueue_owner_multi.go b/pkg/handler/enqueue_owner_multi.go new file mode 100644 index 000000000..eac064ea1 --- /dev/null +++ b/pkg/handler/enqueue_owner_multi.go @@ -0,0 +1,53 @@ +package handler + +import ( + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/workqueue" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +const MongoDBMultiResourceAnnotation = "MongoDBMultiResource" + +var _ handler.EventHandler = &EnqueueRequestForOwnerMultiCluster{} + +// EnqueueRequestForOwnerMultiCluster implements the EventHandler interface for multi-cluster callbacks. +// We cannot reuse the "EnqueueRequestForOwner" because it uses OwnerReference which doesn't work across clusters +type EnqueueRequestForOwnerMultiCluster struct { +} + +func (e *EnqueueRequestForOwnerMultiCluster) Create(evt event.CreateEvent, q workqueue.RateLimitingInterface) { + req := getOwnerMDBCRD(evt.Object.GetAnnotations(), evt.Object.GetNamespace()) + if req != (reconcile.Request{}) { + q.Add(req) + } +} + +func (e *EnqueueRequestForOwnerMultiCluster) Update(evt event.UpdateEvent, q workqueue.RateLimitingInterface) { + reqs := []reconcile.Request{getOwnerMDBCRD(evt.ObjectOld.GetAnnotations(), evt.ObjectOld.GetNamespace()), + getOwnerMDBCRD(evt.ObjectNew.GetAnnotations(), evt.ObjectNew.GetNamespace())} + + for _, req := range reqs { + if req != (reconcile.Request{}) { + q.Add(req) + } + } +} + +func (e *EnqueueRequestForOwnerMultiCluster) Delete(evt event.DeleteEvent, q workqueue.RateLimitingInterface) { + req := getOwnerMDBCRD(evt.Object.GetAnnotations(), evt.Object.GetNamespace()) + q.Add(req) +} + +func (e *EnqueueRequestForOwnerMultiCluster) Generic(evt event.GenericEvent, q workqueue.RateLimitingInterface) { + +} + +func getOwnerMDBCRD(annotations map[string]string, namespace string) reconcile.Request { + val, ok := annotations[MongoDBMultiResourceAnnotation] + if !ok { + return reconcile.Request{} + } + return reconcile.Request{NamespacedName: types.NamespacedName{Name: val, Namespace: namespace}} +} diff --git a/pkg/kube/kube.go b/pkg/kube/kube.go new file mode 100644 index 000000000..380082e34 --- /dev/null +++ b/pkg/kube/kube.go @@ -0,0 +1,30 @@ +package kube + +import ( + v1 "github.com/10gen/ops-manager-kubernetes/api/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func ObjectKey(namespace, name string) client.ObjectKey { + return types.NamespacedName{Name: name, Namespace: namespace} +} + +func ObjectKeyFromApiObject(obj metav1.Object) client.ObjectKey { + return ObjectKey(obj.GetNamespace(), obj.GetName()) +} + +func BaseOwnerReference(owner v1.CustomResourceReadWriter) []metav1.OwnerReference { + if owner == nil { + return []metav1.OwnerReference{} + } + return []metav1.OwnerReference{ + *metav1.NewControllerRef(owner, schema.GroupVersionKind{ + Group: v1.SchemeGroupVersion.Group, + Version: v1.SchemeGroupVersion.Version, + Kind: owner.GetObjectKind().GroupVersionKind().Kind, + }), + } +} diff --git a/pkg/multicluster/failedcluster/failedcluster.go b/pkg/multicluster/failedcluster/failedcluster.go new file mode 100644 index 000000000..0a0b4391b --- /dev/null +++ b/pkg/multicluster/failedcluster/failedcluster.go @@ -0,0 +1,11 @@ +package failedcluster + +const ( + FailedClusterAnnotation = "failedClusters" + ClusterSpecOverrideAnnotation = "clusterSpecOverride" +) + +type FailedCluster struct { + ClusterName string + Members int +} diff --git a/pkg/multicluster/memberwatch/clusterhealth.go b/pkg/multicluster/memberwatch/clusterhealth.go new file mode 100644 index 000000000..247de41b4 --- /dev/null +++ b/pkg/multicluster/memberwatch/clusterhealth.go @@ -0,0 +1,96 @@ +package memberwatch + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "github.com/hashicorp/go-retryablehttp" + "net/http" + "time" + + "github.com/10gen/ops-manager-kubernetes/pkg/multicluster" + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" + "go.uber.org/zap" +) + +type ClusterHealthChecker interface { + IsClusterHealthy() bool +} + +type MemberHeathCheck struct { + Server string + Client *retryablehttp.Client + Token string +} + +var DefaultRetryWaitMin = 1 * time.Second +var DefaultRetryWaitMax = 3 * time.Second +var DefaultRetryMax = 10 + +func NewMemberHealthCheck(server string, ca []byte, token string, log *zap.SugaredLogger) *MemberHeathCheck { + certpool := x509.NewCertPool() + certpool.AppendCertsFromPEM(ca) + + return &MemberHeathCheck{ + Server: server, + Client: &retryablehttp.Client{ + HTTPClient: &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: certpool, + }, + }, + Timeout: time.Duration(env.ReadIntOrDefault(multicluster.ClusterClientTimeoutEnv, 10)) * time.Second, + }, + RetryWaitMin: DefaultRetryWaitMin, + RetryWaitMax: DefaultRetryWaitMax, + RetryMax: DefaultRetryMax, + // Will retry on all errors + CheckRetry: retryablehttp.DefaultRetryPolicy, + // Exponential backoff based on the attempt number and limited by the provided minimum and maximum durations. + // We don't need Jitter here as we're the only client to the OM, so there's no risk + // of overwhelming it in a peek. + Backoff: retryablehttp.DefaultBackoff, + RequestLogHook: func(logger retryablehttp.Logger, request *http.Request, i int) { + if i > 0 { + log.Warnf("Retrying (#%d) failed health check to %s (%s)", i, server, request.URL) + } + }, + }, + Token: token, + } +} + +// IsMemberClusterHealthy checks if there are some member clusters that are not in a "healthy" state +// by curl "ing" the healthz endpoint of the clusters. +func (m *MemberHeathCheck) IsClusterHealthy(log *zap.SugaredLogger) bool { + statusCode, err := check(m.Client, m.Server, m.Token) + if err != nil { + log.Errorf("Error running healthcheck for server: %s, error: %v", m.Server, err) + } + + if err != nil || statusCode != http.StatusOK { + return false + } + + return true +} + +// check pings the "/readyz" endpoint of a cluster's API server and checks if it is healthy +func check(client *retryablehttp.Client, server string, token string) (int, error) { + endPoint := fmt.Sprintf("%s/readyz", server) + req, err := retryablehttp.NewRequest("GET", endPoint, nil) + if err != nil { + return 0, err + } + + bearer := "Bearer " + token + req.Header.Add("Authorization", bearer) + + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + return resp.StatusCode, nil +} diff --git a/pkg/multicluster/memberwatch/clusterhealth_test.go b/pkg/multicluster/memberwatch/clusterhealth_test.go new file mode 100644 index 000000000..63d93aa32 --- /dev/null +++ b/pkg/multicluster/memberwatch/clusterhealth_test.go @@ -0,0 +1,51 @@ +package memberwatch + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.uber.org/zap" +) + +func init() { + logger, _ := zap.NewDevelopment() + zap.ReplaceGlobals(logger) +} + +func TestIsMemberClusterHealthy(t *testing.T) { + // mark cluster as healthy because "200" status code + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(200) + })) + + memberHealthCheck := NewMemberHealthCheck(server.URL, []byte("ca-data"), "bhjkb", zap.S()) + healthy := memberHealthCheck.IsClusterHealthy(zap.S()) + assert.Equal(t, true, healthy) + + // mark cluster unhealthy because != "200" status code + server = httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(500) + })) + + // check retry mechanism + DefaultRetryWaitMin = time.Second * 1 + DefaultRetryWaitMax = time.Second * 1 + DefaultRetryMax = 2 + + startTime := time.Now() + memberHealthCheck = NewMemberHealthCheck(server.URL, []byte("ca-data"), "hhfhj", zap.S()) + healthy = memberHealthCheck.IsClusterHealthy(zap.S()) + endTime := time.Since(startTime) + + assert.Equal(t, false, healthy) + assert.GreaterOrEqual(t, endTime, DefaultRetryWaitMin*2) + assert.LessOrEqual(t, endTime, DefaultRetryWaitMax*2+time.Second) + + // mark cluster unhealthy because of error + memberHealthCheck = NewMemberHealthCheck("", []byte("ca-data"), "bhdjbh", zap.S()) + healthy = memberHealthCheck.IsClusterHealthy(zap.S()) + assert.Equal(t, false, healthy) +} diff --git a/pkg/multicluster/memberwatch/memberwatch.go b/pkg/multicluster/memberwatch/memberwatch.go new file mode 100644 index 000000000..1813e1208 --- /dev/null +++ b/pkg/multicluster/memberwatch/memberwatch.go @@ -0,0 +1,229 @@ +package memberwatch + +import ( + "context" + "encoding/base64" + "encoding/json" + "math" + "time" + + "sigs.k8s.io/controller-runtime/pkg/cluster" + + "github.com/10gen/ops-manager-kubernetes/api/v1/mdbmulti" + "github.com/10gen/ops-manager-kubernetes/pkg/multicluster" + "github.com/10gen/ops-manager-kubernetes/pkg/multicluster/failedcluster" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/annotations" + kubernetesClient "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/client" + "go.uber.org/zap" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" +) + +type MemberClusterHealthChecker struct { + Cache map[string]*MemberHeathCheck +} + +// WatchMemberClusterHealth watches member clusters healthcheck. If a cluster fails healthcheck it re-enqueues the +// MongoDBMultiCluster resources. It is spun up in the mongodb multi reconciler as a go-routine, and is executed every 10 seconds. +func (m *MemberClusterHealthChecker) WatchMemberClusterHealth(log *zap.SugaredLogger, watchChannel chan event.GenericEvent, centralClient kubernetesClient.Client, clustersMap map[string]cluster.Cluster) { + + // check if the local cache is populated if not let's do that + if len(m.Cache) == 0 { + // load the kubeconfig file contents from disk + kubeConfigFile, err := multicluster.NewKubeConfigFile() + if err != nil { + log.Errorf("Failed to read KubeConfig file err: %s", err) + // we can't populate the client so just bail out here + return + } + + kubeConfig, err := kubeConfigFile.LoadKubeConfigFile() + if err != nil { + log.Errorf("Failed to load the kubeconfig file content err: %s", err) + return + } + + for n := range kubeConfig.Contexts { + + clusterName := kubeConfig.Contexts[n].Name + + if _, ok := clustersMap[clusterName]; !ok { + continue + } + + server := kubeConfig.Clusters[n].Cluster.Server + certificateAuthority, err := base64.StdEncoding.DecodeString(kubeConfig.Clusters[n].Cluster.CertificateAuthority) + if err != nil { + log.Errorf("Failed to decode certificate for cluster: %s, err: %s", clusterName, err) + continue + } + + token := kubeConfig.Users[n].User.Token + + m.Cache[clusterName] = NewMemberHealthCheck(server, certificateAuthority, token, log) + + } + } + + for { + log.Info("Running member cluster healthcheck") + mdbmList := &mdbmulti.MongoDBMultiClusterList{} + + err := centralClient.List(context.TODO(), mdbmList, &client.ListOptions{Namespace: ""}) + if err != nil { + log.Errorf("Failed to fetch MongoDBMultiClusterList from Kubernetes : %s", err) + } + + // check the cluster health status corresponding to each member cluster + for k, v := range m.Cache { + if v.IsClusterHealthy(log) { + log.Infof("Cluster %s reported healthy", k) + continue + } + + log.Warnf("Cluster %s reported unhealthy", k) + // re-enqueue all the MDBMultis the operator is watching into the reconcile loop + for _, mdbm := range mdbmList.Items { + + if shouldAddFailedClusterAnnotation(mdbm.Annotations, k) && multicluster.ShouldPerformFailover() { + log.Infof("Enqueuing resource: %s, because cluster %s has failed healthcheck", mdbm.Name, k) + err := AddFailoverAnnotation(mdbm, k, centralClient) + if err != nil { + log.Errorf("Failed to add failover annotation to the mdbmc resource: %s, error: %s", mdbm.Name, err) + } + watchChannel <- event.GenericEvent{Object: &mdbm} + } else if shouldAddFailedClusterAnnotation(mdbm.Annotations, k) { + log.Infof("Marking resource: %s, with failed cluster %s annotation", mdbm.Name, k) + err := addFailedClustersAnnotation(mdbm, k, centralClient) + if err != nil { + log.Errorf("Failed to add failed cluster annotation to the mdbmc resource: %s, error: %s", mdbm.Name, err) + } + } + } + } + time.Sleep(10 * time.Second) + } + +} + +// shouldAddFailedClusterAnnotation checks if we should add this cluster in the failedCluster annotation, +// if it's already not present. +func shouldAddFailedClusterAnnotation(annotations map[string]string, clusterName string) bool { + failedclusters := readFailedClusterAnnotation(annotations) + if failedclusters == nil { + return true + } + + for _, c := range failedclusters { + if c.ClusterName == clusterName { + return false + } + } + return true +} + +// readFailedClusterAnnotation reads the current failed clusters from the annotation. +func readFailedClusterAnnotation(annotations map[string]string) []failedcluster.FailedCluster { + if val, ok := annotations[failedcluster.FailedClusterAnnotation]; ok { + var failedClusters []failedcluster.FailedCluster + + err := json.Unmarshal([]byte(val), &failedClusters) + if err != nil { + return nil + } + + return failedClusters + } + return nil +} + +// clusterWithMinimumMembers returns the index of the cluster with the minimum number of nodes. +func clusterWithMinimumMembers(clusters []mdbmulti.ClusterSpecItem) int { + mini, index := math.MaxInt64, -1 + + for nn, c := range clusters { + if c.Members < mini { + mini = c.Members + index = nn + } + } + return index +} + +// distributeFailedMembers evenly distributes the failed cluster's members amongst the remaining healthy clusters. +func distributeFailedMembers(clusters []mdbmulti.ClusterSpecItem, clustername string) []mdbmulti.ClusterSpecItem { + // add the cluster override annotations. Get the current clusterspec list from the CR and + // increase the members of the first cluster by the number of failed nodes + membersToFailOver := 0 + + for n, c := range clusters { + if c.ClusterName == clustername { + membersToFailOver = c.Members + clusters = append(clusters[:n], clusters[n+1:]...) + break + } + + } + + for membersToFailOver > 0 { + // pick the cluster with the minumum number of nodes currently and increament + // its count by 1. + nn := clusterWithMinimumMembers(clusters) + clusters[nn].Members += 1 + membersToFailOver -= 1 + } + + return clusters +} + +// AddFailoverAnnotation adds the failed cluster spec to the annotation of the MongoDBMultiCluster CR for it to be used +// while performing the reconcilliation +func AddFailoverAnnotation(mrs mdbmulti.MongoDBMultiCluster, clustername string, client kubernetesClient.Client) error { + if mrs.Annotations == nil { + mrs.Annotations = map[string]string{} + } + + addFailedClustersAnnotation(mrs, clustername, client) + + currentClusterSpecs := mrs.Spec.ClusterSpecList + currentClusterSpecs = distributeFailedMembers(currentClusterSpecs, clustername) + + updatedClusterSpec, err := json.Marshal(currentClusterSpecs) + if err != nil { + return err + } + + return annotations.SetAnnotations(&mrs, map[string]string{failedcluster.ClusterSpecOverrideAnnotation: string(updatedClusterSpec)}, client) + +} + +func addFailedClustersAnnotation(mrs mdbmulti.MongoDBMultiCluster, clustername string, client kubernetesClient.Client) error { + if mrs.Annotations == nil { + mrs.Annotations = map[string]string{} + } + + // read the existing failed cliuster annotations + var clusterData []failedcluster.FailedCluster + failedclusters := readFailedClusterAnnotation(mrs.Annotations) + if failedclusters != nil { + clusterData = failedclusters + } + + clusterData = append(clusterData, failedcluster.FailedCluster{ClusterName: clustername, + Members: getClusterMembers(mrs.Spec.ClusterSpecList, clustername)}) + + clusterDataBytes, err := json.Marshal(clusterData) + if err != nil { + return err + } + return annotations.SetAnnotations(&mrs, map[string]string{failedcluster.FailedClusterAnnotation: string(clusterDataBytes)}, client) +} + +func getClusterMembers(clusterSpecList []mdbmulti.ClusterSpecItem, clusterName string) int { + for _, e := range clusterSpecList { + if e.ClusterName == clusterName { + return e.Members + } + } + return 0 +} diff --git a/pkg/multicluster/memberwatch/memberwatch_test.go b/pkg/multicluster/memberwatch/memberwatch_test.go new file mode 100644 index 000000000..d83b595ac --- /dev/null +++ b/pkg/multicluster/memberwatch/memberwatch_test.go @@ -0,0 +1,149 @@ +package memberwatch + +import ( + "encoding/json" + "testing" + + "github.com/10gen/ops-manager-kubernetes/api/v1/mdbmulti" + "github.com/10gen/ops-manager-kubernetes/pkg/multicluster/failedcluster" + "github.com/stretchr/testify/assert" +) + +func TestClusterWithMinimumNumber(t *testing.T) { + tests := []struct { + inp []mdbmulti.ClusterSpecItem + out int + }{ + { + inp: []mdbmulti.ClusterSpecItem{ + {ClusterName: "cluster1", Members: 2}, + {ClusterName: "cluster2", Members: 1}, + {ClusterName: "cluster3", Members: 4}, + {ClusterName: "cluster4", Members: 1}, + }, + out: 1, + }, + { + inp: []mdbmulti.ClusterSpecItem{ + {ClusterName: "cluster1", Members: 1}, + {ClusterName: "cluster2", Members: 2}, + {ClusterName: "cluster3", Members: 3}, + {ClusterName: "cluster4", Members: 4}, + }, + out: 0, + }, + } + + for _, tt := range tests { + assert.Equal(t, tt.out, clusterWithMinimumMembers(tt.inp)) + } +} + +func TestDistributeFailedMembers(t *testing.T) { + tests := []struct { + inp []mdbmulti.ClusterSpecItem + clusterName string + out []mdbmulti.ClusterSpecItem + }{ + { + inp: []mdbmulti.ClusterSpecItem{ + {ClusterName: "cluster1", Members: 2}, + {ClusterName: "cluster2", Members: 1}, + {ClusterName: "cluster3", Members: 4}, + {ClusterName: "cluster4", Members: 1}, + }, + clusterName: "cluster1", + out: []mdbmulti.ClusterSpecItem{ + {ClusterName: "cluster2", Members: 2}, + {ClusterName: "cluster3", Members: 4}, + {ClusterName: "cluster4", Members: 2}, + }, + }, + { + inp: []mdbmulti.ClusterSpecItem{ + {ClusterName: "cluster1", Members: 2}, + {ClusterName: "cluster2", Members: 1}, + {ClusterName: "cluster3", Members: 4}, + {ClusterName: "cluster4", Members: 1}, + }, + clusterName: "cluster2", + out: []mdbmulti.ClusterSpecItem{ + {ClusterName: "cluster1", Members: 2}, + {ClusterName: "cluster3", Members: 4}, + {ClusterName: "cluster4", Members: 2}, + }, + }, + { + inp: []mdbmulti.ClusterSpecItem{ + {ClusterName: "cluster1", Members: 2}, + {ClusterName: "cluster2", Members: 1}, + {ClusterName: "cluster3", Members: 4}, + {ClusterName: "cluster4", Members: 1}, + }, + clusterName: "cluster3", + out: []mdbmulti.ClusterSpecItem{ + {ClusterName: "cluster1", Members: 3}, + {ClusterName: "cluster2", Members: 3}, + {ClusterName: "cluster4", Members: 2}, + }, + }, + { + inp: []mdbmulti.ClusterSpecItem{ + {ClusterName: "cluster1", Members: 2}, + {ClusterName: "cluster2", Members: 1}, + {ClusterName: "cluster3", Members: 4}, + {ClusterName: "cluster4", Members: 1}, + }, + clusterName: "cluster4", + out: []mdbmulti.ClusterSpecItem{ + {ClusterName: "cluster1", Members: 2}, + {ClusterName: "cluster2", Members: 2}, + {ClusterName: "cluster3", Members: 4}, + }, + }, + } + + for _, tt := range tests { + assert.Equal(t, tt.out, distributeFailedMembers(tt.inp, tt.clusterName)) + } + +} + +func getFailedClusterList(clusters []string) string { + failedClusters := make([]failedcluster.FailedCluster, len(clusters)) + + for n, c := range clusters { + failedClusters[n] = failedcluster.FailedCluster{ClusterName: c, Members: 2} + } + + failedClusterBytes, _ := json.Marshal(failedClusters) + return string(failedClusterBytes) +} + +func TestShouldAddFailedClusterAnnotation(t *testing.T) { + tests := []struct { + annotations map[string]string + clusterName string + out bool + }{ + { + annotations: nil, + clusterName: "cluster1", + out: true, + }, + { + annotations: map[string]string{failedcluster.FailedClusterAnnotation: getFailedClusterList([]string{"cluster1", "cluster2"})}, + clusterName: "cluster1", + out: false, + }, + { + annotations: map[string]string{failedcluster.FailedClusterAnnotation: getFailedClusterList([]string{"cluster1", "cluster2", "cluster4"})}, + clusterName: "cluster3", + out: true, + }, + } + + for _, tt := range tests { + assert.Equal(t, shouldAddFailedClusterAnnotation(tt.annotations, tt.clusterName), tt.out) + } +} diff --git a/pkg/multicluster/mockedcluster.go b/pkg/multicluster/mockedcluster.go new file mode 100644 index 000000000..324d3c30c --- /dev/null +++ b/pkg/multicluster/mockedcluster.go @@ -0,0 +1,65 @@ +package multicluster + +import ( + "context" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/cluster" +) + +var _ cluster.Cluster = &MockedCluster{} + +type MockedCluster struct { + client client.Client +} + +func (m *MockedCluster) SetFields(interface{}) error { + return nil +} + +func (m *MockedCluster) GetConfig() *rest.Config { + return nil +} + +func (m *MockedCluster) GetScheme() *runtime.Scheme { + return nil +} + +func (m *MockedCluster) GetClient() client.Client { + return m.client +} + +func (m *MockedCluster) GetFieldIndexer() client.FieldIndexer { + return nil +} + +func (m *MockedCluster) GetCache() cache.Cache { + return nil +} + +func (m *MockedCluster) GetEventRecorderFor(name string) record.EventRecorder { + return nil +} + +func (m *MockedCluster) GetRESTMapper() meta.RESTMapper { + return nil +} + +func (m *MockedCluster) GetAPIReader() client.Reader { + return nil +} + +func (m *MockedCluster) Start(ctx context.Context) error { + return nil +} + +func New(client client.Client) *MockedCluster { + return &MockedCluster{ + client: client, + } +} diff --git a/pkg/multicluster/multicluster.go b/pkg/multicluster/multicluster.go new file mode 100644 index 000000000..f58478229 --- /dev/null +++ b/pkg/multicluster/multicluster.go @@ -0,0 +1,163 @@ +package multicluster + +import ( + "fmt" + "io" + "io/ioutil" + "os" + "strconv" + "strings" + "time" + + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" + "github.com/ghodss/yaml" + "golang.org/x/xerrors" + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +const ( + // kubeconfig path holding the credentials for different member clusters + DefaultKubeConfigPath = "/etc/config/kubeconfig/kubeconfig" + KubeConfigPathEnv = "KUBE_CONFIG_PATH" + ClusterClientTimeoutEnv = "CLUSTER_CLIENT_TIMEOUT" +) + +type KubeConfig struct { + Reader io.Reader +} + +func NewKubeConfigFile() (KubeConfig, error) { + file, err := os.Open(GetKubeConfigPath()) + if err != nil { + return KubeConfig{}, err + } + return KubeConfig{Reader: file}, nil +} + +func GetKubeConfigPath() string { + return env.ReadOrDefault(KubeConfigPathEnv, DefaultKubeConfigPath) +} + +// LoadKubeConfigFile returns the KubeConfig file containing the multi cluster context. +func (k KubeConfig) LoadKubeConfigFile() (KubeConfigFile, error) { + kubeConfigBytes, err := ioutil.ReadAll(k.Reader) + if err != nil { + return KubeConfigFile{}, err + } + + kubeConfig := KubeConfigFile{} + if err := yaml.Unmarshal(kubeConfigBytes, &kubeConfig); err != nil { + return KubeConfigFile{}, err + } + return kubeConfig, nil +} + +// CreateMemberClusterClients creates a client(map of cluster-name to client) to talk to the API-Server corresponding to each member clusters. +func CreateMemberClusterClients(clusterNames []string) (map[string]*restclient.Config, error) { + clusterClientsMap := map[string]*restclient.Config{} + + for _, c := range clusterNames { + clientset, err := getClient(c, GetKubeConfigPath()) + if err != nil { + return nil, xerrors.Errorf("failed to create clientset map: %w", err) + } + if clientset == nil { + return nil, xerrors.Errorf("failed to get clientset for cluster: %s", c) + } + clientset.Timeout = time.Duration(env.ReadIntOrDefault(ClusterClientTimeoutEnv, 10)) * time.Second + clusterClientsMap[c] = clientset + } + return clusterClientsMap, nil +} + +// getClient returns a kubernetes.Clientset using the given context from the +// specified KubeConfig filepath. +func getClient(context, kubeConfigPath string) (*restclient.Config, error) { + config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeConfigPath}, + &clientcmd.ConfigOverrides{ + CurrentContext: context, + }).ClientConfig() + + if err != nil { + return nil, xerrors.Errorf("failed to create client config: %w", err) + } + + return config, nil +} + +// IsMultiClusterMode checks if the operator is running in multi-cluster mode. +// In multi-cluster mode the operator is passed the name of the CRD in command line arguments. +func IsMultiClusterMode(crdsToWatch string) bool { + return strings.Contains(crdsToWatch, "mongodbmulticluster") +} + +// shouldPerformFailover checks if the operator is configured to perform automatic failover +// of the MongoDB Replicaset members spread over multiple Kubernetes clusters. +func ShouldPerformFailover() bool { + str := os.Getenv("PERFORM_FAILOVER") + val, err := strconv.ParseBool(str) + if err != nil { + return false + } + return val +} + +// KubeConfigFile represents the contents of a KubeConfig file. +type KubeConfigFile struct { + Contexts []KubeConfigContextItem `json:"contexts"` + Clusters []KubeConfigClusterItem `json:"clusters"` + Users []KubeConfigUserItem `json:"users"` +} + +type KubeConfigClusterItem struct { + Cluster KubeConfigCluster `json:"cluster"` +} + +type KubeConfigCluster struct { + CertificateAuthority string `json:"certificate-authority-data"` + Server string `json:"server"` +} + +type KubeConfigUserItem struct { + User KubeConfigUser `json:"user"` +} + +type KubeConfigUser struct { + Token string `json:"token"` +} +type KubeConfigContextItem struct { + Name string `json:"name"` + Context KubeConfigContext `json:"context"` +} + +type KubeConfigContext struct { + Cluster string `json:"cluster"` + Namespace string `json:"namespace"` +} + +// GetMemberClusterNamespace returns the namespace that will be used for all member clusters. +func (k KubeConfigFile) GetMemberClusterNamespace() string { + return k.Contexts[0].Context.Namespace +} + +// MustGetClusterNumFromMultiStsName parses the statefulset object name and returns the cluster number where it is created +func MustGetClusterNumFromMultiStsName(name string) int { + ss := strings.Split(name, "-") + + n, err := strconv.Atoi(ss[len(ss)-1]) + if err != nil { + panic(err) + } + return n +} + +// GetRsNamefromMultiStsName parese the statefulset object name and returns the name of MongoDBMultiCluster object name +func GetRsNamefromMultiStsName(name string) string { + ss := strings.Split(name, "-") + if len(ss) <= 1 || ss[0] == "" { + panic(fmt.Sprintf("invalid statefulset name: %s", name)) + } + return strings.Join(ss[:len(ss)-1], "-") +} diff --git a/pkg/multicluster/multicluster_test.go b/pkg/multicluster/multicluster_test.go new file mode 100644 index 000000000..b33494fd2 --- /dev/null +++ b/pkg/multicluster/multicluster_test.go @@ -0,0 +1,136 @@ +package multicluster + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetRsNamefromMultiStsName(t *testing.T) { + tests := []struct { + inp string + want string + }{ + { + inp: "foo-bar-test-1", + want: "foo-bar-test", + }, + { + inp: "foo-0", + want: "foo", + }, + { + inp: "foo-bar-1-2", + want: "foo-bar-1", + }, + } + + for _, tt := range tests { + got := GetRsNamefromMultiStsName(tt.inp) + assert.Equal(t, tt.want, got) + } +} + +func TestGetRsNamefromMultiStsNamePanic(t *testing.T) { + tests := []struct { + inp string + }{ + { + inp: "", + }, + { + inp: "-1", + }, + } + + for _, tt := range tests { + assert.Panics(t, func() { GetRsNamefromMultiStsName(tt.inp) }, "The code did not panic") + } + +} + +func TestLoadKubeConfigFile(t *testing.T) { + inp := []struct { + server string + name string + ca string + cluster string + }{ + { + server: "https://api.e2e.cluster1.mongokubernetes.com", + name: "e2e.cluster1.mongokubernetes.com", + ca: "abcdbbd", + }, + { + server: "https://api.e2e.cluster2.mongokubernetes.com", + name: "e2e.cluster2.mongokubernetes.com", + ca: "njcdkn", + }, + { + server: "https://api.e2e.cluster3.mongokubernetes.com", + name: "e2e.cluster3.mongokubernetes.com", + ca: "nxjknjk", + }, + } + + str := fmt.Sprintf( + ` +apiVersion: v1 +clusters: +- cluster: + certificate-authority-data: %s + server: %s + name: %s +- cluster: + certificate-authority-data: %s + server: %s + name: %s +- cluster: + certificate-authority-data: %s + server: %s + name: %s +contexts: +- context: + cluster: e2e.cluster1.mongokubernetes.com + namespace: a-1661872869-pq35wlt3zzz + user: e2e.cluster1.mongokubernetes.com + name: e2e.cluster1.mongokubernetes.com +- context: + cluster: e2e.cluster2.mongokubernetes.com + namespace: a-1661872869-pq35wlt3zzz + user: e2e.cluster2.mongokubernetes.com + name: e2e.cluster2.mongokubernetes.com +- context: + cluster: e2e.cluster3.mongokubernetes.com + namespace: a-1661872869-pq35wlt3zzz + user: e2e.cluster3.mongokubernetes.com + name: e2e.cluster3.mongokubernetes.com +kind: Config +users: +- name: e2e.cluster1.mongokubernetes.com + user: + token: eyJhbGciOi +- name: e2e.cluster2.mongokubernetes.com + user: + token: eyJhbGc +- name: e2e.cluster3.mongokubernetes.com + user: + token: njncdnjn`, inp[0].ca, inp[0].server, inp[0].name, inp[1].ca, + inp[1].server, inp[1].name, inp[2].ca, inp[1].server, inp[1].name) + + k := KubeConfig{ + Reader: strings.NewReader(str), + } + + arr, err := k.LoadKubeConfigFile() + if err != nil { + t.Error(err) + } + + for n, e := range arr.Contexts { + assert.Equal(t, inp[n].name, e.Name) + assert.Equal(t, inp[n].name, e.Context.Cluster) + } +} diff --git a/pkg/passwordhash/passwordhash.go b/pkg/passwordhash/passwordhash.go new file mode 100644 index 000000000..65b0b6796 --- /dev/null +++ b/pkg/passwordhash/passwordhash.go @@ -0,0 +1,29 @@ +package passwordhash + +import ( + "crypto/sha256" + + "github.com/10gen/ops-manager-kubernetes/pkg/util/generate" + "golang.org/x/crypto/pbkdf2" + + "encoding/base64" +) + +const ( + HashIterations = 256 + HashLength = 32 +) + +// GenerateHashAndSaltForPassword returns a `hash` and `salt` for this password. +func GenerateHashAndSaltForPassword(password string) (string, string) { + salt, err := generate.GenerateRandomBytes(8) + if err != nil { + return "", "" + } + + // The implementation at + // https://github.com/10gen/mms-automation/blob/76078d46d56a91a7ca2edc91b811ee87682b24b6/go_planner/src/com.tengen/cm/metrics/prometheus/server.go#L207 + // is the counterpart of this code. + hash := pbkdf2.Key([]byte(password), salt, HashIterations, HashLength, sha256.New) + return base64.StdEncoding.EncodeToString(hash), base64.StdEncoding.EncodeToString(salt) +} diff --git a/pkg/statefulset/statefulset_test.go b/pkg/statefulset/statefulset_test.go new file mode 100644 index 000000000..8b780f7ae --- /dev/null +++ b/pkg/statefulset/statefulset_test.go @@ -0,0 +1,583 @@ +package statefulset + +import ( + "fmt" + "testing" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/statefulset" + + "github.com/mongodb/mongodb-kubernetes-operator/pkg/util/merge" + + "github.com/stretchr/testify/assert" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + TestNamespace = "test-ns" + TestName = "test-name" +) + +func int64Ref(i int64) *int64 { + return &i +} + +func TestGetContainerIndexByName(t *testing.T) { + containers := []corev1.Container{ + { + Name: "container-0", + }, + { + Name: "container-1", + }, + { + Name: "container-2", + }, + } + + stsBuilder := defaultStatefulSetBuilder().SetPodTemplateSpec(podTemplateWithContainers(containers)) + idx, err := stsBuilder.GetContainerIndexByName("container-0") + + assert.NoError(t, err) + assert.NotEqual(t, -1, idx) + assert.Equal(t, 0, idx) + + idx, err = stsBuilder.GetContainerIndexByName("container-1") + + assert.NoError(t, err) + assert.NotEqual(t, -1, idx) + assert.Equal(t, 1, idx) + + idx, err = stsBuilder.GetContainerIndexByName("container-2") + + assert.NoError(t, err) + assert.NotEqual(t, -1, idx) + assert.Equal(t, 2, idx) + + idx, err = stsBuilder.GetContainerIndexByName("doesnt-exist") + + assert.Error(t, err) + assert.Equal(t, -1, idx) +} + +func TestAddVolumeAndMount(t *testing.T) { + var stsBuilder *statefulset.Builder + var sts appsv1.StatefulSet + var err error + vmd := statefulset.VolumeMountData{ + MountPath: "mount-path", + Name: "mount-name", + ReadOnly: true, + Volume: statefulset.CreateVolumeFromConfigMap("mount-name", "config-map"), + } + + stsBuilder = defaultStatefulSetBuilder().SetPodTemplateSpec(podTemplateWithContainers([]corev1.Container{{Name: "container-name"}})).AddVolumeAndMount(vmd, "container-name") + sts, err = stsBuilder.Build() + + // assert container was correctly updated with the volumes + assert.NoError(t, err, "volume should successfully mount when the container exists") + assert.Len(t, sts.Spec.Template.Spec.Containers[0].VolumeMounts, 1, "volume mount should have been added to the container in the stateful set") + assert.Equal(t, sts.Spec.Template.Spec.Containers[0].VolumeMounts[0].Name, "mount-name") + assert.Equal(t, sts.Spec.Template.Spec.Containers[0].VolumeMounts[0].MountPath, "mount-path") + + // assert the volumes were added to the podspec template + assert.Len(t, sts.Spec.Template.Spec.Volumes, 1) + assert.Equal(t, sts.Spec.Template.Spec.Volumes[0].Name, "mount-name") + assert.NotNil(t, sts.Spec.Template.Spec.Volumes[0].VolumeSource.ConfigMap, "volume should have been configured from a config map source") + assert.Nil(t, sts.Spec.Template.Spec.Volumes[0].VolumeSource.Secret, "volume should not have been configured from a secret source") + + stsBuilder = defaultStatefulSetBuilder().SetPodTemplateSpec(podTemplateWithContainers([]corev1.Container{{Name: "container-0"}, {Name: "container-1"}})).AddVolumeAndMount(vmd, "container-0") + sts, err = stsBuilder.Build() + + assert.NoError(t, err, "volume should successfully mount when the container exists") + + secretVmd := statefulset.VolumeMountData{ + MountPath: "mount-path-secret", + Name: "mount-name-secret", + ReadOnly: true, + Volume: statefulset.CreateVolumeFromSecret("mount-name-secret", "secret"), + } + + // add a 2nd container to previously defined stsBuilder + sts, err = stsBuilder.AddVolumeAndMount(secretVmd, "container-1").Build() + + assert.NoError(t, err, "volume should successfully mount when the container exists") + assert.Len(t, sts.Spec.Template.Spec.Containers[1].VolumeMounts, 1, "volume mount should have been added to the container in the stateful set") + assert.Equal(t, sts.Spec.Template.Spec.Containers[1].VolumeMounts[0].Name, "mount-name-secret") + assert.Equal(t, sts.Spec.Template.Spec.Containers[1].VolumeMounts[0].MountPath, "mount-path-secret") + + assert.Len(t, sts.Spec.Template.Spec.Volumes, 2) + assert.Equal(t, sts.Spec.Template.Spec.Volumes[1].Name, "mount-name-secret") + assert.Nil(t, sts.Spec.Template.Spec.Volumes[1].VolumeSource.ConfigMap, "volume should not have been configured from a config map source") + assert.NotNil(t, sts.Spec.Template.Spec.Volumes[1].VolumeSource.Secret, "volume should have been configured from a secret source") + +} + +func TestAddVolumeClaimTemplates(t *testing.T) { + claim := corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "claim-0", + }, + } + mount := corev1.VolumeMount{ + Name: "mount-0", + } + sts, err := defaultStatefulSetBuilder().SetPodTemplateSpec(podTemplateWithContainers([]corev1.Container{{Name: "container-name"}})).AddVolumeClaimTemplates([]corev1.PersistentVolumeClaim{claim}).AddVolumeMounts("container-name", []corev1.VolumeMount{mount}).Build() + + assert.NoError(t, err) + assert.Len(t, sts.Spec.VolumeClaimTemplates, 1) + assert.Equal(t, sts.Spec.VolumeClaimTemplates[0].Name, "claim-0") + assert.Len(t, sts.Spec.Template.Spec.Containers[0].VolumeMounts, 1) + assert.Equal(t, sts.Spec.Template.Spec.Containers[0].VolumeMounts[0].Name, "mount-0") +} + +func TestBuildStructImmutable(t *testing.T) { + labels := map[string]string{"label_1": "a", "label_2": "b"} + stsBuilder := defaultStatefulSetBuilder().SetLabels(labels).SetReplicas(2) + var sts appsv1.StatefulSet + var err error + sts, err = stsBuilder.Build() + assert.NoError(t, err) + assert.Len(t, sts.ObjectMeta.Labels, 2) + assert.Equal(t, *sts.Spec.Replicas, int32(2)) + + delete(labels, "label_2") + // checks that modifying the underlying object did not change the built statefulset + assert.Len(t, sts.ObjectMeta.Labels, 2) + assert.Equal(t, *sts.Spec.Replicas, int32(2)) + sts, err = stsBuilder.Build() + assert.NoError(t, err) + assert.Len(t, sts.ObjectMeta.Labels, 1) + assert.Equal(t, *sts.Spec.Replicas, int32(2)) +} + +func defaultStatefulSetBuilder() *statefulset.Builder { + return statefulset.NewBuilder(). + SetName(TestName). + SetNamespace(TestNamespace). + SetServiceName(fmt.Sprintf("%s-svc", TestName)). + SetLabels(map[string]string{}) +} + +func podTemplateWithContainers(containers []corev1.Container) corev1.PodTemplateSpec { + return corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: containers, + }, + } +} + +func TestBuildStatefulSet_SortedEnvVariables(t *testing.T) { + podTemplateSpec := podTemplateWithContainers([]corev1.Container{{Name: "container-name"}}) + podTemplateSpec.Spec.Containers[0].Env = []corev1.EnvVar{ + {Name: "one", Value: "X"}, + {Name: "two", Value: "Y"}, + {Name: "three", Value: "Z"}, + } + sts, err := defaultStatefulSetBuilder().SetPodTemplateSpec(podTemplateSpec).Build() + assert.NoError(t, err) + expectedVars := []corev1.EnvVar{ + {Name: "one", Value: "X"}, + {Name: "three", Value: "Z"}, + {Name: "two", Value: "Y"}, + } + assert.Equal(t, expectedVars, sts.Spec.Template.Spec.Containers[0].Env) +} + +func getDefaultContainer() corev1.Container { + return corev1.Container{ + Name: "container-0", + Image: "image-0", + ReadinessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{HTTPGet: &corev1.HTTPGetAction{ + Path: "/foo", + }}, + PeriodSeconds: 10, + }, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "container-0.volume-mount-0", + }, + }, + } +} + +func getCustomContainer() corev1.Container { + return corev1.Container{ + Name: "container-1", + Image: "image-1", + } +} + +func getDefaultPodSpec() corev1.PodTemplateSpec { + initContainer := getDefaultContainer() + initContainer.Name = "init-container-default" + return corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-default-name", + Namespace: "my-default-namespace", + Labels: map[string]string{"app": "operator"}, + }, + Spec: corev1.PodSpec{ + NodeSelector: map[string]string{ + "node-0": "node-0", + }, + ServiceAccountName: "my-default-service-account", + TerminationGracePeriodSeconds: int64Ref(12), + ActiveDeadlineSeconds: int64Ref(10), + Containers: []corev1.Container{getDefaultContainer()}, + InitContainers: []corev1.Container{initContainer}, + Affinity: affinity("hostname", "default"), + }, + } +} + +func affinity(antiAffinityKey, nodeAffinityKey string) *corev1.Affinity { + return &corev1.Affinity{ + PodAntiAffinity: &corev1.PodAntiAffinity{ + PreferredDuringSchedulingIgnoredDuringExecution: []corev1.WeightedPodAffinityTerm{{ + PodAffinityTerm: corev1.PodAffinityTerm{ + TopologyKey: antiAffinityKey, + }, + }}, + }, + NodeAffinity: &corev1.NodeAffinity{ + RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{NodeSelectorTerms: []corev1.NodeSelectorTerm{{ + MatchFields: []corev1.NodeSelectorRequirement{{ + Key: nodeAffinityKey, + }}, + }}}, + }, + } +} + +func getCustomPodSpec() corev1.PodTemplateSpec { + initContainer := getCustomContainer() + initContainer.Name = "init-container-custom" + return corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"custom": "some"}, + }, + Spec: corev1.PodSpec{ + NodeSelector: map[string]string{ + "node-1": "node-1", + }, + ServiceAccountName: "my-service-account-override", + TerminationGracePeriodSeconds: int64Ref(11), + NodeName: "my-node-name", + RestartPolicy: corev1.RestartPolicyAlways, + Containers: []corev1.Container{getCustomContainer()}, + InitContainers: []corev1.Container{initContainer}, + Affinity: affinity("zone", "custom"), + }, + } +} + +func TestMergePodSpecsEmptyCustom(t *testing.T) { + + defaultPodSpec := getDefaultPodSpec() + customPodSpecTemplate := corev1.PodTemplateSpec{} + + mergedPodTemplateSpec := merge.PodTemplateSpecs(defaultPodSpec, customPodSpecTemplate) + assert.Equal(t, "my-default-service-account", mergedPodTemplateSpec.Spec.ServiceAccountName) + assert.Equal(t, int64Ref(12), mergedPodTemplateSpec.Spec.TerminationGracePeriodSeconds) + + assert.Equal(t, "my-default-name", mergedPodTemplateSpec.ObjectMeta.Name) + assert.Equal(t, "my-default-namespace", mergedPodTemplateSpec.ObjectMeta.Namespace) + assert.Equal(t, int64Ref(10), mergedPodTemplateSpec.Spec.ActiveDeadlineSeconds) + + // ensure collections have been merged + assert.Contains(t, mergedPodTemplateSpec.Spec.NodeSelector, "node-0") + assert.Len(t, mergedPodTemplateSpec.Spec.Containers, 1) + assert.Equal(t, "container-0", mergedPodTemplateSpec.Spec.Containers[0].Name) + assert.Equal(t, "image-0", mergedPodTemplateSpec.Spec.Containers[0].Image) + assert.Equal(t, "container-0.volume-mount-0", mergedPodTemplateSpec.Spec.Containers[0].VolumeMounts[0].Name) + assert.Len(t, mergedPodTemplateSpec.Spec.InitContainers, 1) + assert.Equal(t, "init-container-default", mergedPodTemplateSpec.Spec.InitContainers[0].Name) +} + +func TestMergePodSpecsEmptyDefault(t *testing.T) { + + defaultPodSpec := corev1.PodTemplateSpec{} + customPodSpecTemplate := getCustomPodSpec() + + mergedPodTemplateSpec := merge.PodTemplateSpecs(customPodSpecTemplate, defaultPodSpec) + + assert.Equal(t, "my-service-account-override", mergedPodTemplateSpec.Spec.ServiceAccountName) + assert.Equal(t, int64Ref(11), mergedPodTemplateSpec.Spec.TerminationGracePeriodSeconds) + assert.Equal(t, "my-node-name", mergedPodTemplateSpec.Spec.NodeName) + assert.Equal(t, corev1.RestartPolicy("Always"), mergedPodTemplateSpec.Spec.RestartPolicy) + + assert.Len(t, mergedPodTemplateSpec.Spec.Containers, 1) + assert.Equal(t, "container-1", mergedPodTemplateSpec.Spec.Containers[0].Name) + assert.Equal(t, "image-1", mergedPodTemplateSpec.Spec.Containers[0].Image) + assert.Len(t, mergedPodTemplateSpec.Spec.InitContainers, 1) + assert.Equal(t, "init-container-custom", mergedPodTemplateSpec.Spec.InitContainers[0].Name) + +} + +func TestMergePodSpecsBoth(t *testing.T) { + + defaultPodSpec := getDefaultPodSpec() + customPodSpecTemplate := getCustomPodSpec() + + var mergedPodTemplateSpec corev1.PodTemplateSpec + + // multiple merges must give the same result + for i := 0; i < 3; i++ { + mergedPodTemplateSpec = merge.PodTemplateSpecs(defaultPodSpec, customPodSpecTemplate) + // ensure values that were specified in the custom pod spec template remain unchanged + assert.Equal(t, "my-service-account-override", mergedPodTemplateSpec.Spec.ServiceAccountName) + assert.Equal(t, int64Ref(11), mergedPodTemplateSpec.Spec.TerminationGracePeriodSeconds) + assert.Equal(t, "my-node-name", mergedPodTemplateSpec.Spec.NodeName) + assert.Equal(t, corev1.RestartPolicy("Always"), mergedPodTemplateSpec.Spec.RestartPolicy) + + // ensure values from the default pod spec template have been merged in + assert.Equal(t, "my-default-name", mergedPodTemplateSpec.ObjectMeta.Name) + assert.Equal(t, "my-default-namespace", mergedPodTemplateSpec.ObjectMeta.Namespace) + assert.Equal(t, int64Ref(10), mergedPodTemplateSpec.Spec.ActiveDeadlineSeconds) + + // ensure collections have been merged + assert.Contains(t, mergedPodTemplateSpec.Spec.NodeSelector, "node-0") + assert.Contains(t, mergedPodTemplateSpec.Spec.NodeSelector, "node-1") + assert.Len(t, mergedPodTemplateSpec.Spec.Containers, 2) + assert.Equal(t, "container-0", mergedPodTemplateSpec.Spec.Containers[0].Name) + assert.Equal(t, "image-0", mergedPodTemplateSpec.Spec.Containers[0].Image) + assert.Equal(t, "container-0.volume-mount-0", mergedPodTemplateSpec.Spec.Containers[0].VolumeMounts[0].Name) + assert.Equal(t, "container-1", mergedPodTemplateSpec.Spec.Containers[1].Name) + assert.Equal(t, "image-1", mergedPodTemplateSpec.Spec.Containers[1].Image) + assert.Len(t, mergedPodTemplateSpec.Spec.InitContainers, 2) + assert.Equal(t, "init-container-custom", mergedPodTemplateSpec.Spec.InitContainers[0].Name) + assert.Equal(t, "init-container-default", mergedPodTemplateSpec.Spec.InitContainers[1].Name) + + // ensure labels were appended + assert.Len(t, mergedPodTemplateSpec.Labels, 2) + assert.Contains(t, mergedPodTemplateSpec.Labels, "app") + assert.Contains(t, mergedPodTemplateSpec.Labels, "custom") + + // ensure the pointers are not the same + assert.NotEqual(t, mergedPodTemplateSpec.Spec.Affinity, defaultPodSpec.Spec.Affinity) + + // ensure the affinity rules slices were overridden + assert.Equal(t, affinity("zone", "custom"), mergedPodTemplateSpec.Spec.Affinity) + } +} + +func TestMergeSpec(t *testing.T) { + t.Run("Add Container to PodSpecTemplate", func(t *testing.T) { + sts, err := defaultStatefulSetBuilder().Build() + assert.NoError(t, err) + customSts, err := defaultStatefulSetBuilder().SetPodTemplateSpec(podTemplateWithContainers([]corev1.Container{{Name: "container-0"}})).Build() + assert.NoError(t, err) + + mergedSpec := merge.StatefulSetSpecs(sts.Spec, customSts.Spec) + assert.Contains(t, mergedSpec.Template.Spec.Containers, corev1.Container{Name: "container-0"}) + }) + t.Run("Change terminationGracePeriodSeconds", func(t *testing.T) { + sts, err := defaultStatefulSetBuilder().Build() + assert.NoError(t, err) + sts.Spec.Template.Spec.TerminationGracePeriodSeconds = int64Ref(30) + customSts, err := defaultStatefulSetBuilder().SetPodTemplateSpec(podTemplateWithContainers([]corev1.Container{{Name: "container-0"}})).Build() + sts.Spec.Template.Spec.TerminationGracePeriodSeconds = int64Ref(600) + assert.NoError(t, err) + + mergedSpec := merge.StatefulSetSpecs(sts.Spec, customSts.Spec) + assert.Contains(t, mergedSpec.Template.Spec.Containers, corev1.Container{Name: "container-0"}) + assert.Equal(t, mergedSpec.Template.Spec.TerminationGracePeriodSeconds, int64Ref(600)) + }) + t.Run("Containers are added to existing list", func(t *testing.T) { + sts, err := defaultStatefulSetBuilder().SetPodTemplateSpec(podTemplateWithContainers([]corev1.Container{{Name: "container-0"}})).Build() + assert.NoError(t, err) + customSts, err := defaultStatefulSetBuilder().SetPodTemplateSpec(podTemplateWithContainers([]corev1.Container{{Name: "container-1"}})).Build() + assert.NoError(t, err) + + mergedSpec := merge.StatefulSetSpecs(sts.Spec, customSts.Spec) + assert.Len(t, mergedSpec.Template.Spec.Containers, 2) + assert.Contains(t, mergedSpec.Template.Spec.Containers, corev1.Container{Name: "container-0"}) + assert.Contains(t, mergedSpec.Template.Spec.Containers, corev1.Container{Name: "container-1"}) + }) + t.Run("Cannot change fields in the StatefulSet outside of the spec", func(t *testing.T) { + sts, err := defaultStatefulSetBuilder().Build() + assert.NoError(t, err) + customSts, err := defaultStatefulSetBuilder().Build() + assert.NoError(t, err) + customSts.Annotations = map[string]string{ + "some-annotation": "some-value", + } + mergedSts := merge.StatefulSets(sts, customSts) + assert.NotContains(t, mergedSts.Annotations, "some-annotation") + }) + t.Run("change fields in the StatefulSet the Operator doesn't touch", func(t *testing.T) { + sts, err := defaultStatefulSetBuilder().Build() + assert.NoError(t, err) + customSts, err := defaultStatefulSetBuilder().AddVolumeClaimTemplates([]corev1.PersistentVolumeClaim{{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-volume-claim", + }, + }}).Build() + assert.NoError(t, err) + + mergedSpec := merge.StatefulSetSpecs(sts.Spec, customSts.Spec) + assert.Len(t, mergedSpec.VolumeClaimTemplates, 1) + assert.Equal(t, "my-volume-claim", mergedSpec.VolumeClaimTemplates[0].Name) + }) + t.Run("Volume Claim Templates are added to existing StatefulSet", func(t *testing.T) { + sts, err := defaultStatefulSetBuilder().AddVolumeClaimTemplates([]corev1.PersistentVolumeClaim{{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-volume-claim-0", + }, + }}).Build() + + assert.NoError(t, err) + customSts, err := defaultStatefulSetBuilder().AddVolumeClaimTemplates([]corev1.PersistentVolumeClaim{{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-volume-claim-1", + }, + }}).Build() + assert.NoError(t, err) + + mergedSpec := merge.StatefulSetSpecs(sts.Spec, customSts.Spec) + assert.Len(t, mergedSpec.VolumeClaimTemplates, 2) + assert.Equal(t, "my-volume-claim-0", mergedSpec.VolumeClaimTemplates[0].Name) + assert.Equal(t, "my-volume-claim-1", mergedSpec.VolumeClaimTemplates[1].Name) + }) + + t.Run("Volume Claim Templates are changed by name", func(t *testing.T) { + sts, err := defaultStatefulSetBuilder().AddVolumeClaimTemplates([]corev1.PersistentVolumeClaim{{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-volume-claim-0", + Namespace: "first-ns", + }, + }}).Build() + + assert.NoError(t, err) + customSts, err := defaultStatefulSetBuilder().AddVolumeClaimTemplates([]corev1.PersistentVolumeClaim{{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-volume-claim-0", + Namespace: "new-ns", + }, + }}).Build() + assert.NoError(t, err) + + mergedSpec := merge.StatefulSetSpecs(sts.Spec, customSts.Spec) + assert.Len(t, mergedSpec.VolumeClaimTemplates, 1) + assert.Equal(t, "my-volume-claim-0", mergedSpec.VolumeClaimTemplates[0].Name) + assert.Equal(t, "new-ns", mergedSpec.VolumeClaimTemplates[0].Namespace) + }) + + t.Run("Volume Claims are added", func(t *testing.T) { + sts, err := defaultStatefulSetBuilder(). + SetPodTemplateSpec(getDefaultPodSpec()). + AddVolumeAndMount(statefulset.VolumeMountData{ + MountPath: "path", + Name: "vol-0", + ReadOnly: false, + Volume: corev1.Volume{ + Name: "vol-0", + }, + }, "container-0").Build() + assert.NoError(t, err) + customSts, err := defaultStatefulSetBuilder(). + SetPodTemplateSpec(getDefaultPodSpec()). + AddVolumeAndMount(statefulset.VolumeMountData{ + MountPath: "path-1", + Name: "vol-1", + ReadOnly: false, + Volume: corev1.Volume{ + Name: "vol-1", + }, + }, "container-0"). + AddVolumeAndMount(statefulset.VolumeMountData{ + MountPath: "path-2", + Name: "vol-2", + ReadOnly: false, + Volume: corev1.Volume{ + Name: "vol-2", + }, + }, "container-0").Build() + assert.NoError(t, err) + + mergedSpec := merge.StatefulSetSpecs(sts.Spec, customSts.Spec) + + assert.Len(t, mergedSpec.Template.Spec.Volumes, 3) + for i, vol := range mergedSpec.Template.Spec.Volumes { + assert.Equal(t, fmt.Sprintf("vol-%d", i), vol.Name) + } + }) + + t.Run("Custom StatefulSet zero values don't override operator configured ones", func(t *testing.T) { + sts, err := defaultStatefulSetBuilder().SetServiceName("service-name").Build() + assert.NoError(t, err) + customSts, err := defaultStatefulSetBuilder().SetServiceName("").Build() + assert.NoError(t, err) + mergedSpec := merge.StatefulSetSpecs(sts.Spec, customSts.Spec) + assert.Equal(t, mergedSpec.ServiceName, "service-name") + }) +} + +func TestMergingVolumeMounts(t *testing.T) { + container0 := corev1.Container{ + Name: "container-0", + VolumeMounts: []corev1.VolumeMount{ + { + Name: "database-scripts", + MountPath: "/opt/scripts", + SubPath: "", + }, + { + Name: "data", + MountPath: "/data", + SubPath: "data", + }, + { + Name: "data", + MountPath: "/journal", + SubPath: "journal", + }, + { + Name: "data", + MountPath: "/var/log/mongodb-mms-automation", + SubPath: "logs", + }, + }, + } + + container1 := corev1.Container{ + Name: "container-0", + VolumeMounts: []corev1.VolumeMount{ + { + Name: "test-volume", + MountPath: "/somewhere", + SubPath: "", + }, + }, + } + + podSpec0 := corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + container0, + }, + }, + } + + podSpec1 := corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + container1, + }, + }, + } + + merged := merge.PodTemplateSpecs(podSpec0, podSpec1) + + assert.Len(t, merged.Spec.Containers, 1) + mounts := merged.Spec.Containers[0].VolumeMounts + + assert.Equal(t, container0.VolumeMounts[1], mounts[0]) + assert.Equal(t, container0.VolumeMounts[2], mounts[1]) + assert.Equal(t, container0.VolumeMounts[3], mounts[2]) + assert.Equal(t, container0.VolumeMounts[0], mounts[3]) + assert.Equal(t, container1.VolumeMounts[0], mounts[4]) +} diff --git a/pkg/statefulset/statefulset_util.go b/pkg/statefulset/statefulset_util.go new file mode 100644 index 000000000..9b0506dba --- /dev/null +++ b/pkg/statefulset/statefulset_util.go @@ -0,0 +1,140 @@ +package statefulset + +import ( + "fmt" + "reflect" + + "github.com/10gen/ops-manager-kubernetes/controllers/operator/certs" + + "github.com/10gen/ops-manager-kubernetes/pkg/kube" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/statefulset" + "go.uber.org/zap" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apiErrors "k8s.io/apimachinery/pkg/api/errors" +) + +// isVolumeClaimUpdatableTo takes two sts' PVC and returns wether we are allowed to update the first one to the second one. +func isVolumeClaimUpdatableTo(existing, desired corev1.PersistentVolumeClaim) bool { + + oldSpec := existing.Spec + newSpec := desired.Spec + + if !reflect.DeepEqual(oldSpec.AccessModes, newSpec.AccessModes) { + return false + } + + if newSpec.Selector != nil && !reflect.DeepEqual(oldSpec.Selector, newSpec.Selector) { + return false + } + + if !reflect.DeepEqual(oldSpec.Resources, newSpec.Resources) { + return false + } + + if newSpec.VolumeName != "" && newSpec.VolumeName != oldSpec.VolumeName { + return false + } + + if newSpec.StorageClassName != nil && !reflect.DeepEqual(oldSpec.StorageClassName, newSpec.StorageClassName) { + return false + + } + + if newSpec.VolumeMode != nil && !reflect.DeepEqual(newSpec.VolumeMode, oldSpec.VolumeMode) { + return false + } + + if newSpec.DataSource != nil && !reflect.DeepEqual(newSpec.DataSource, oldSpec.DataSource) { + return false + } + + return true +} + +// isStatefulSetUpdatableTo takes two statefulsts and returns wether we are allowed to update the first one to the second one. +func isStatefulSetUpdatableTo(existing, desired appsv1.StatefulSet) bool { + selectorsEqual := desired.Spec.Selector == nil || reflect.DeepEqual(existing.Spec.Selector, desired.Spec.Selector) + serviceNamesEqual := existing.Spec.ServiceName == desired.Spec.ServiceName + podMgmtEqual := desired.Spec.PodManagementPolicy == "" || desired.Spec.PodManagementPolicy == existing.Spec.PodManagementPolicy + revHistoryLimitEqual := desired.Spec.RevisionHistoryLimit == nil || reflect.DeepEqual(desired.Spec.RevisionHistoryLimit, existing.Spec.RevisionHistoryLimit) + + if len(existing.Spec.VolumeClaimTemplates) != len(desired.Spec.VolumeClaimTemplates) { + return false + } + + // VolumeClaimTemplates must be checked one-by-one, to deal with empty string, nil pointers + for index, existingClaim := range existing.Spec.VolumeClaimTemplates { + if !isVolumeClaimUpdatableTo(existingClaim, desired.Spec.VolumeClaimTemplates[index]) { + return false + } + } + + return selectorsEqual && serviceNamesEqual && podMgmtEqual && revHistoryLimitEqual +} + +// StatefulSetCantBeUpdatedError is returned when we are trying to update immutable fields on a sts. +type StatefulSetCantBeUpdatedError struct { + msg string +} + +func (s StatefulSetCantBeUpdatedError) Error() string { + return s.msg +} + +// CreateOrUpdateStatefulset will create or update a StatefulSet in Kubernetes. +// +// The method has to be flexible (create/update) as there are cases when custom resource is created but statefulset - not +// Service named "serviceName" is created optionally (it may already exist - created by either user or by operator before) +// Note the logic for "exposeExternally" parameter: if it is true then the second service is created of type "NodePort" +// (the random port will be allocated by Kubernetes) otherwise only one service of type "ClusterIP" is created and it +// won't be connectible from external (unless pods in statefulset expose themselves to outside using "hostNetwork: true") +// Function returns the service port number assigned +func CreateOrUpdateStatefulset(getUpdateCreator statefulset.GetUpdateCreator, ns string, log *zap.SugaredLogger, statefulSetToCreate *appsv1.StatefulSet) (*appsv1.StatefulSet, error) { + log = log.With("statefulset", kube.ObjectKey(ns, statefulSetToCreate.Name)) + existingStatefulSet, err := getUpdateCreator.GetStatefulSet(kube.ObjectKey(ns, statefulSetToCreate.Name)) + if err != nil { + if apiErrors.IsNotFound(err) { + if err = getUpdateCreator.CreateStatefulSet(*statefulSetToCreate); err != nil { + return nil, err + } + } else { + return nil, err + } + log.Debug("Created StatefulSet") + return statefulSetToCreate, nil + } + + // preserve existing certificate hash if new one is not statefulSetToCreate + existingCertHash, okExisting := existingStatefulSet.Spec.Template.Annotations[certs.CertHashAnnotationKey] + newCertHash, okNew := statefulSetToCreate.Spec.Template.Annotations[certs.CertHashAnnotationKey] + if existingCertHash != "" && newCertHash == "" && okExisting && okNew { + statefulSetToCreate.Spec.Template.Annotations[certs.CertHashAnnotationKey] = existingCertHash + } + + log.Debug("Checking if we can update the current statefulset") + if !isStatefulSetUpdatableTo(existingStatefulSet, *statefulSetToCreate) { + log.Debug("Can't update the stateful set") + return nil, StatefulSetCantBeUpdatedError{ + msg: "can't execute update on forbidden fields", + } + } + + updatedSts, err := getUpdateCreator.UpdateStatefulSet(*statefulSetToCreate) + if err != nil { + return nil, err + } + return &updatedSts, nil +} + +// func GetFilePathFromAnnotationOrDefault returns a concatenation of a default path and an annotation, or a default value +// if the annotation is not present. +func GetFilePathFromAnnotationOrDefault(sts appsv1.StatefulSet, key string, path string, defaultValue string) string { + val, ok := sts.Annotations[key] + + if ok { + return fmt.Sprintf("%s/%s", path, val) + } + + return defaultValue +} diff --git a/pkg/tls/tls.go b/pkg/tls/tls.go new file mode 100644 index 000000000..e0ab2533e --- /dev/null +++ b/pkg/tls/tls.go @@ -0,0 +1,69 @@ +package tls + +import ( + "fmt" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/util/maputil" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/statefulset" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" +) + +type Mode string + +const ( + Disabled Mode = "disabled" + Require Mode = "requireTLS" + Prefer Mode = "preferTLS" + Allow Mode = "allowTLS" + ConfigMapVolumeCAName = "secret-ca" +) + +func GetTLSModeFromMongodConfig(config map[string]interface{}) Mode { + // spec.Security.TLSConfig.IsEnabled() is true -> requireSSLMode + if config == nil { + return Require + } + mode := maputil.ReadMapValueAsString(config, "net", "tls", "mode") + + if mode == "" { + mode = maputil.ReadMapValueAsString(config, "net", "ssl", "mode") + } + if mode == "" { + return Require + } + + return Mode(mode) +} + +// ConfigureStatefulSet modifies the provided StatefulSet with the required volumes. +func ConfigureStatefulSet(sts *appsv1.StatefulSet, resourceName, prefix, ca string) { + if sts == nil || resourceName == "" { + return + } + // In this location the certificates will be linked -s into server.pem + secretName := fmt.Sprintf("%s-cert", resourceName) + if prefix != "" { + // Certificates will be used from the secret with the corresponding prefix. + secretName = fmt.Sprintf("%s-%s-cert-pem", prefix, resourceName) + } + + secretVolume := statefulset.CreateVolumeFromSecret(util.SecretVolumeName, secretName) + sts.Spec.Template.Spec.Containers[0].VolumeMounts = append(sts.Spec.Template.Spec.Containers[0].VolumeMounts, corev1.VolumeMount{ + MountPath: util.TLSCertMountPath, + Name: secretVolume.Name, + ReadOnly: true, + }) + sts.Spec.Template.Spec.Volumes = append(sts.Spec.Template.Spec.Volumes, secretVolume) + + if ca != "" { + caVolume := statefulset.CreateVolumeFromConfigMap(ConfigMapVolumeCAName, ca) + sts.Spec.Template.Spec.Containers[0].VolumeMounts = append(sts.Spec.Template.Spec.Containers[0].VolumeMounts, corev1.VolumeMount{ + MountPath: util.TLSCaMountPath, + Name: caVolume.Name, + ReadOnly: true, + }) + sts.Spec.Template.Spec.Volumes = append(sts.Spec.Template.Spec.Volumes, caVolume) + } +} diff --git a/pkg/util/constants.go b/pkg/util/constants.go new file mode 100644 index 000000000..2a762a1ca --- /dev/null +++ b/pkg/util/constants.go @@ -0,0 +1,309 @@ +package util + +import ( + "strings" +) + +const ( + // MongoDbStandaloneController name of the Standalone controller + MongoDbStandaloneController = "mongodbstandalone-controller" + + // MongoDbReplicaSetController name of the ReplicaSet controller + MongoDbReplicaSetController = "mongodbreplicaset-controller" + + // MongoDbMultiClusterController is the name of the MongoDB Multi controller + MongoDbMultiClusterController = "mongodbmulticluster-controller" + + //MongoDbMultiReplicaSetController name of the multi-cluster ReplicaSet controller + MongoDbMultiReplicaSetController = "mongodbmultireplicaset-controller" + + // MongoDbShardedClusterController name of the ShardedCluster controller + MongoDbShardedClusterController = "mongodbshardedcluster-controller" + + // MongoDbUserController name of the MongoDBUser controller + MongoDbUserController = "mongodbuser-controller" + + // MongoDbOpsManagerController name of the OpsManager controller + MongoDbOpsManagerController = "opsmanager-controller" + + // Ops manager config map and secret variables + OmBaseUrl = "baseUrl" + OmOrgId = "orgId" + OmProjectName = "projectName" + OldOmPublicApiKey = "publicApiKey" + OldOmUser = "user" + OmPublicApiKey = "publicKey" + OmPrivateKey = "privateKey" + OmAgentApiKey = "agentApiKey" + OmCredentials = "credentials" + + // SSLRequireValidMMSServerCertificates points at the string name of the + // same name variable in OM configuration passed in the "Project" config + SSLRequireValidMMSServerCertificates = "sslRequireValidMMSServerCertificates" + + // SSLTrustedMMSServerCertificate points at the string name of the + // same name variable in OM configuration passed in the "Project" config + SSLTrustedMMSServerCertificate = "sslTrustedMMSServerCertificate" + + // SSLMMSCAConfigMap indicates the name of the ConfigMap that stores the + // CA certificate used to sign the MMS TLS certificate + SSLMMSCAConfigMap = "sslMMSCAConfigMap" + + // UseCustomCAConfigMap flags the operator to try to generate certificates + // (if false) or to not generate them (if true). + UseCustomCAConfigMap = "useCustomCA" + + SSLMMSCAMountPath = PvcMmsHomeMountPath + "/certs" + // SSLMMSCALocation Specifies where the CA certificate should be mounted. + SSLMMSCALocation = SSLMMSCAMountPath + "/ca.crt" + // CaCertMMS is the name of the CA file provided for MMS. + CaCertMMS = "mms-ca.crt" + + // Env variables names for pods + EnvVarBaseUrl = "BASE_URL" + EnvVarProjectId = "GROUP_ID" + EnvVarUser = "USER_LOGIN" + EnvVarLogLevel = "LOG_LEVEL" + + // EnvVarDebug is used to decide whether we want to start the agent in debug mode + EnvVarDebug = "MDB_AGENT_DEBUG" + EnvVarMultiClusterMode = "MULTI_CLUSTER_MODE" + + // EnvVarSSLRequireValidMMSCertificates bla bla + EnvVarSSLRequireValidMMSCertificates = "SSL_REQUIRE_VALID_MMS_CERTIFICATES" + + // EnvVarSSLTrustedMMSServerCertificate env variable will point to where the CA cert is mounted. + EnvVarSSLTrustedMMSServerCertificate = "SSL_TRUSTED_MMS_SERVER_CERTIFICATE" + + // Pod/StatefulSet specific constants + OperatorName = "mongodb-enterprise-operator" + MultiClusterOperatorName = "mongodb-enterprise-operator-multi-cluster" + OpsManagerContainerName = "mongodb-ops-manager" + BackupDaemonContainerName = "mongodb-backup-daemon" + DatabaseContainerName = "mongodb-enterprise-database" + AppDbContainerName = "mongod" + InitOpsManagerContainerName = "mongodb-enterprise-init-ops-manager" + PvcNameData = "data" + PvcMountPathData = "/data" + PvcNameJournal = "journal" + PvcMountPathJournal = "/journal" + PvcNameLogs = "logs" + PvcMountPathLogs = "/var/log/mongodb-mms-automation" + PvcNameHeadDb = "head" + PvcMountPathHeadDb = "/head/" + PvcNameTmp = "tmp" + PvcMountPathTmp = "/tmp" + PvcMmsHome = "mongodb-automation" + PvcMmsHomeMountPath = "/mongodb-automation" + CAFilePathInContainer = PvcMmsHomeMountPath + "/ca.pem" + PEMKeyFilePathInContainer = PvcMmsHomeMountPath + "/server.pem" + KMIPSecretsHome = "/mongodb-ops-manager/kmip" + KMIPServerCAHome = KMIPSecretsHome + "/server" + KMIPClientSecretsHome = KMIPSecretsHome + "/client" + KMIPServerCAName = "kmip-server" + KMIPClientSecretNamePrefix = "kmip-client-" + KMIPCAFileInContainer = KMIPServerCAHome + "/ca.pem" + PvcMms = "mongodb-mms-automation" + PvcMmsMountPath = "/var/lib/mongodb-mms-automation" + PvMms = "agent" + AgentDownloadsDir = PvcMmsMountPath + "/downloads" + + MmsPemKeyFileDirInContainer = "/opt/mongodb/mms/secrets" + AppDBMmsCaFileDirInContainer = "/opt/mongodb/mms/ca/" + + AutomationAgentName = "mms-automation-agent" + AutomationAgentPemSecretKey = AutomationAgentName + "-pem" + AutomationAgentPemFilePath = PvcMmsHomeMountPath + "/" + AgentSecretName + "/" + AutomationAgentPemSecretKey + RunAsUser = 2000 + FsGroup = 2000 + + // Service accounts + OpsManagerServiceAccount = "mongodb-enterprise-ops-manager" + MongoDBServiceAccount = "mongodb-enterprise-database-pods" + + // Authentication + AgentSecretName = "agent-certs" + AutomationConfigX509Option = "MONGODB-X509" + AutomationConfigLDAPOption = "PLAIN" + AutomationConfigScramSha256Option = "SCRAM-SHA-256" + AutomationConfigScramSha1Option = "MONGODB-CR" + AutomationAgentUserName = "mms-automation-agent" + RequireClientCertificates = "REQUIRE" + OptionalClientCertficates = "OPTIONAL" + ClusterFileName = "clusterfile" + InternalClusterAuthMountPath = PvcMmsHomeMountPath + "/cluster-auth/" + DefaultUserDatabase = "admin" + X509 = "X509" + SCRAM = "SCRAM" + SCRAMSHA1 = "SCRAM-SHA-1" + MONGODBCR = "MONGODB-CR" + SCRAMSHA256 = "SCRAM-SHA-256" + LDAP = "LDAP" + MinimumScramSha256MdbVersion = "4.0.0" + + // these were historically used and constituted a security issue—if set they should be changed + InvalidKeyFileContents = "DUMMYFILE" + InvalidAutomationAgentPassword = "D9XK2SfdR2obIevI9aKsYlVH" + + // AutomationAgentWindowsKeyFilePath is the default path for the windows key file. This is never + // used, but we want to keep it the default value so it is is possible to add new users without modifying + // it. Ops Manager will attempt to reset this value to the default if new MongoDB users are added + // when x509 auth is enabled + AutomationAgentWindowsKeyFilePath = "%SystemDrive%\\MMSAutomation\\versions\\keyfile" + + //AutomationAgentKeyFilePathInContainer is the default path of the keyfile and should be + // kept as is for the same reason as above + AutomationAgentKeyFilePathInContainer = PvcMmsMountPath + "/keyfile" + + // Operator Env configuration properties. Please note that when adding environment variables to this list, + // make sure you append them to util.go:PrintEnvVars function's `printableEnvPrefixes` if you need the + // new variable to be printed at operator start. + OpsManagerImageUrl = "OPS_MANAGER_IMAGE_REPOSITORY" + InitOpsManagerImageUrl = "INIT_OPS_MANAGER_IMAGE_REPOSITORY" + InitOpsManagerVersion = "INIT_OPS_MANAGER_VERSION" + InitAppdbImageUrlEnv = "INIT_APPDB_IMAGE_REPOSITORY" + InitDatabaseImageUrlEnv = "INIT_DATABASE_IMAGE_REPOSITORY" + OpsManagerPullPolicy = "OPS_MANAGER_IMAGE_PULL_POLICY" + AutomationAgentImage = "MONGODB_ENTERPRISE_DATABASE_IMAGE" + AutomationAgentImagePullPolicy = "IMAGE_PULL_POLICY" + ImagePullSecrets = "IMAGE_PULL_SECRETS" + OmOperatorEnv = "OPERATOR_ENV" + + MemberListConfigMapName = "mongodb-enterprise-operator-member-list" + + BackupDisableWaitSecondsEnv = "BACKUP_WAIT_SEC" + BackupDisableWaitRetriesEnv = "BACKUP_WAIT_RETRIES" + ManagedSecurityContextEnv = "MANAGED_SECURITY_CONTEXT" + CurrentNamespace = "CURRENT_NAMESPACE" + WatchNamespace = "WATCH_NAMESPACE" + OpsManagerMonitorAppDB = "OPS_MANAGER_MONITOR_APPDB" + + // Different default configuration values + DefaultMongodStorageSize = "16G" + DefaultConfigSrvStorageSize = "5G" + DefaultJournalStorageSize = "1G" // maximum size for single journal file is 100Mb, journal files are removed soon after checkpoints + DefaultLogsStorageSize = "3G" + DefaultHeadDbStorageSize = "32G" + DefaultMemoryAppDB = "500M" + DefaultMemoryOpsManager = "5G" + DefaultAntiAffinityTopologyKey = "kubernetes.io/hostname" + MongoDbDefaultPort = 27017 + OpsManagerDefaultPortHTTP = 8080 + OpsManagerDefaultPortHTTPS = 8443 + DefaultBackupDisableWaitSeconds = "3" + DefaultBackupDisableWaitRetries = "30" // 30 * 3 = 90 seconds, should be ok for backup job to terminate + DefaultPodTerminationPeriodSeconds = 600 // 10 min. Keep this in sync with 'cleanup()' function in agent-launcher-lib.sh + DefaultK8sCacheRefreshTimeSeconds = 2 + OpsManagerMonitorAppDBDefault = true + + // S3 constants + S3AccessKey = "accessKey" + S3SecretKey = "secretKey" + DefaultS3MaxConnections = 50 + + // Ops Manager related constants + OmPropertyPrefix = "OM_PROP_" + MmsJvmParamEnvVar = "CUSTOM_JAVA_MMS_UI_OPTS" + BackupDaemonJvmParamEnvVar = "CUSTOM_JAVA_DAEMON_OPTS" + GenKeyPath = "/mongodb-ops-manager/.mongodb-mms" + LatestOmVersion = "5.0" + AppDBAutomationConfigKey = "cluster-config.json" + AppDBMonitoringAutomationConfigKey = "monitoring-cluster-config.json" + DefaultAppDbPasswordKey = "password" + AppDbConnectionStringKey = "connectionString" + AppDbProjectIdKey = "projectId" + + // Below is a list of non-persistent PV and PVCs for OpsManager + OpsManagerPvcNameData = "data" + OpsManagerPvcNameConf = "conf" + OpsManagerPvcMountPathConf = "/mongodb-ops-manager/conf" + OpsManagerPvcNameLogs = "logs" + OpsManagerPvcMountPathLogs = "/mongodb-ops-manager/logs" + OpsManagerPvcNameTmp = "tmp-ops-manager" + OpsManagerPvcMountPathTmp = "/mongodb-ops-manager/tmp" + OpsManagerPvcNameDownloads = "mongodb-releases" + OpsManagerPvcMountDownloads = "/mongodb-ops-manager/mongodb-releases" + OpsManagerPvcNameEtc = "etc-ops-manager" + OpsManagerPvcMountPathEtc = "/etc/mongodb-mms" + + // Ops Manager configuration properties + MmsCentralUrlPropKey = "mms.centralUrl" + MmsMongoUri = "mongo.mongoUri" + MmsMongoSSL = "mongo.ssl" + MmsMongoCA = "mongodb.ssl.CAFile" + MmsFeatureControls = "mms.featureControls.enable" + MmsHeaderContainVersion = "mms.serviceVersionApiHeader.enabled" + MmsVersionsDirectory = "automation.versions.directory" + MmsPEMKeyFile = "mms.https.PEMKeyFile" + BrsQueryablePem = "brs.queryable.pem" + + // SecretVolumeMountPath defines where in the Pod will be the secrets + // object mounted. + SecretVolumeMountPath = "/var/lib/mongodb-automation/secrets" + + SecretVolumeMountPathPrometheus = SecretVolumeMountPath + "/prometheus" + + TLSCertMountPath = PvcMmsHomeMountPath + "/tls" + TLSCaMountPath = PvcMmsHomeMountPath + "/tls/ca" + + // TODO: remove this from here and move it to the certs package + // This currently creates an import cycle + InternalCertAnnotationKey = "internalCertHash" + LastAchievedSpec = "mongodb.com/v1.lastSuccessfulConfiguration" + LastAchievedMongodAdditionalOptions = "mongodb.com/v1.lastMongodAdditionalOptionsConfiguration" + LastAchievedMongodAdditionalShardOptions = "mongodb.com/v1.lastMongodAdditionalShardOptionsConfiguration" + LastAchievedMongodAdditionalMongosOptions = "mongodb.com/v1.lastMongodAdditionalMongosOptionsConfiguration" + LastAchievedMongodAdditionalConfigServerOptions = "mongodb.com/v1.lastMongodAdditionalConfigServerOptionsConfiguration" + + // SecretVolumeName is the name of the volume resource. + SecretVolumeName = "secret-certs" + + // PrometheusSecretVolumeName + PrometheusSecretVolumeName = "prometheus-certs" + + // ConfigMapVolumeCAMountPath defines where CA root certs will be + // mounted in the pod + ConfigMapVolumeCAMountPath = SecretVolumeMountPath + "/ca" + + // Ops Manager authentication constants + OpsManagerMongoDBUserName = "mongodb-ops-manager" + OpsManagerPasswordKey = "password" + + // Env variables used for testing mostly to decrease waiting time + PodWaitSecondsEnv = "POD_WAIT_SEC" + PodWaitRetriesEnv = "POD_WAIT_RETRIES" + K8sCacheRefreshEnv = "K8S_CACHES_REFRESH_TIME_SEC" + AppDBReadinessWaitEnv = "APPDB_STATEFULSET_WAIT_SEC" + + // All others + OmGroupExternallyManagedTag = "EXTERNALLY_MANAGED_BY_KUBERNETES" + GenericErrorMessage = "Something went wrong validating your Automation Config" + MethodNotAllowed = "405 (Method Not Allowed)" + + RetryTimeSec = 10 + + DeprecatedImageAppdbUbiUrl = "mongodb-enterprise-appdb-database-ubi" + + OfficialServerImageAppdbUrl = "mongodb-enterprise-server" + + MdbAppdbAssumeOldFormat = "MDB_APPDB_ASSUME_OLD_FORMAT" +) + +const ( + OperatorEnvironmentDev string = "dev" + OperatorEnvironmentProd = "prod" + OperatorEnvironmentLocal = "local" +) + +// ***** These variables are set at compile time + +// OperatorVersion is the version of the current Operator. Important: currently it's empty when the Operator is +// installed for development (using 'make') meaning the Ops Manager/AppDB images deployed won't have +// "operator specific" part of the version tag +var OperatorVersion string + +var LogAutomationConfigDiff string + +func ShouldLogAutomationConfigDiff() bool { + return strings.EqualFold(LogAutomationConfigDiff, "true") +} diff --git a/pkg/util/env/env.go b/pkg/util/env/env.go new file mode 100644 index 000000000..775e06a44 --- /dev/null +++ b/pkg/util/env/env.go @@ -0,0 +1,131 @@ +package env + +import ( + "fmt" + "os" + "sort" + "strconv" + "strings" + + corev1 "k8s.io/api/core/v1" + + "github.com/spf13/cast" + "go.uber.org/zap" +) + +func Read(env string) (string, bool) { + return os.LookupEnv(env) +} + +func ReadBool(env string) (valueAsBool bool, isPresent bool) { + value, isPresent := Read(env) + if !isPresent { + return false, false + } + boolValue, err := strconv.ParseBool(value) + return boolValue, err == nil +} + +func ReadBoolOrDefault(key string, defaultValue bool) bool { + value, isPresent := ReadBool(key) + if isPresent { + return value + } + return defaultValue +} + +// EnsureVar tests the env variable and sets it if it doesn't exist. We tolerate any errors setting env variable and +// just log the warning +func EnsureVar(key, value string) { + if _, exist := Read(key); !exist { + if err := os.Setenv(key, value); err != nil { + zap.S().Warnf("Failed to set environment variable \"%s\" to \"%s\": %s", key, value, err) + } + } +} + +// PrintWithPrefix prints environment variables to the global SugaredLogger. It will only print the environment variables +// with a given prefix set inside the function. +func PrintWithPrefix(printableEnvPrefixes []string) { + zap.S().Info("Environment variables:") + envVariables := os.Environ() + sort.Strings(envVariables) + for _, e := range envVariables { + for _, prefix := range printableEnvPrefixes { + if strings.HasPrefix(e, prefix) { + zap.S().Infof("%s", e) + break + } + } + } +} + +func ReadOrPanic(key string) string { + value := os.Getenv(key) + if value == "" { + panic(fmt.Sprintf("%s environment variable is not set!", key)) + } + return value +} + +func ReadIntOrPanic(key string) int { + value := os.Getenv(key) + i, e := cast.ToIntE(value) + if e != nil { + panic(fmt.Sprintf("%s env variable is supposed to be of type int but the value is %s", key, value)) + } + return i +} + +func ReadOrDefault(key string, dflt string) string { + value, exists := os.LookupEnv(key) + if !exists || value == "" { + return dflt + } + return value +} + +func ReadIntOrDefault(key string, dflt int) int { + value := ReadOrDefault(key, strconv.Itoa(dflt)) + i, e := cast.ToIntE(value) + if e != nil { + return dflt + } + return i +} + +// PodEnvVars is a convenience struct to pass environment variables to Pods as needed. +// They are used by the automation agent to connect to Ops/Cloud Manager. +type PodEnvVars struct { + BaseURL string + ProjectID string + User string + AgentAPIKey string + LogLevel string + + // Related to MMS SSL configuration + SSLProjectConfig +} + +// SSLProjectConfig contains the configuration options that are relevant for MMS SSL configuration +type SSLProjectConfig struct { + // This is set to true if baseUrl is HTTPS + SSLRequireValidMMSServerCertificates bool + + // Name of a configmap containing a `mms-ca.crt` entry that will be mounted + // on every Pod. + SSLMMSCAConfigMap string + + // SSLMMSCAConfigMap will contain the CA cert, used to push multiple + SSLMMSCAConfigMapContents string +} + +// ToMap accepts a variable number of EnvVars and returns them as a map +// with the name as the key. +func ToMap(vars ...corev1.EnvVar) map[string]string { + variablesMap := map[string]string{} + for _, envVar := range vars { + variablesMap[envVar.Name] = envVar.Value + } + return variablesMap +} diff --git a/pkg/util/generate/generate.go b/pkg/util/generate/generate.go new file mode 100644 index 000000000..8e32211a2 --- /dev/null +++ b/pkg/util/generate/generate.go @@ -0,0 +1,48 @@ +package generate + +import ( + "crypto/rand" + "encoding/base64" + mrand "math/rand" + "time" +) + +var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ123") + +// final key must be between 6 and at most 1024 characters +func KeyFileContents() (string, error) { + return generateRandomString(500) +} + +func RandomFixedLengthStringOfSize(n int) (string, error) { + b, err := GenerateRandomBytes(n) + return base64.URLEncoding.EncodeToString(b)[:n], err +} + +func GenerateRandomBytes(size int) ([]byte, error) { + b := make([]byte, size) + _, err := rand.Read(b) + if err != nil { + return nil, err + } + + return b, nil +} + +func generateRandomString(numBytes int) (string, error) { + b, err := GenerateRandomBytes(numBytes) + return base64.StdEncoding.EncodeToString(b), err +} + +func randSeq(n int) string { + b := make([]rune, n) + for i := range b { + b[i] = letters[mrand.Intn(len(letters))] + } + return string(b) +} + +func GenerateRandomPassword() string { + mrand.Seed(time.Now().UnixNano()) + return randSeq(10) +} diff --git a/pkg/util/identifiable/identifiable.go b/pkg/util/identifiable/identifiable.go new file mode 100644 index 000000000..f86691f01 --- /dev/null +++ b/pkg/util/identifiable/identifiable.go @@ -0,0 +1,90 @@ +package identifiable + +import ( + "reflect" +) + +// Identifiable is a simple interface wrapping any object which has some key field which can be used for later +// aggregation operations (grouping, intersection, difference etc) +type Identifiable interface { + Identifier() interface{} +} + +// SetDifference returns all 'Identifiable' elements that are in left slice and not in the right one +func SetDifference(left, right []Identifiable) []Identifiable { + result := make([]Identifiable, 0) + for _, l := range left { + found := false + for _, r := range right { + if r.Identifier() == l.Identifier() { + found = true + break + } + } + if !found { + result = append(result, l) + } + } + return result +} + +// SetIntersection returns all 'Identifiable' elements from 'left' and 'right' slice that intersect by 'Identifier()' +// value. Each intersection is represented as a tuple of two elements - matching elements from 'left' and 'right' +func SetIntersection(left, right []Identifiable) [][]Identifiable { + result := make([][]Identifiable, 0) + for _, l := range left { + for _, r := range right { + if r.Identifier() == l.Identifier() { + result = append(result, []Identifiable{l, r}) + } + } + } + return result +} + +// SetDifferenceGeneric is a convenience function solving lack of covariance in Go: it allows to pass the arrays declared +// as some types implementing 'Identifiable' and find the difference between them +// Important: the arrays past must declare types implementing 'Identifiable'! +func SetDifferenceGeneric(left, right interface{}) []Identifiable { + leftIdentifiers := toIdentifiableSlice(left) + rightIdentifiers := toIdentifiableSlice(right) + + return SetDifference(leftIdentifiers, rightIdentifiers) +} + +// SetIntersectionGeneric is a convenience function solving lack of covariance in Go: it allows to pass the arrays declared +// as some types implementing 'Identifiable' and find the intersection between them +// Important: the arrays past must declare types implementing 'Identifiable'! +func SetIntersectionGeneric(left, right interface{}) [][]Identifiable { + leftIdentifiers := toIdentifiableSlice(left) + rightIdentifiers := toIdentifiableSlice(right) + + // check if there is a difference in the config with same ID + found := false + for _, l := range leftIdentifiers { + for _, r := range rightIdentifiers { + if l.Identifier() == r.Identifier() { + if l != r { + found = true + break + } + } + } + } + if !found { + return nil + } + + return SetIntersection(leftIdentifiers, rightIdentifiers) +} + +// toIdentifiableSlice uses reflection to cast the array +func toIdentifiableSlice(data interface{}) []Identifiable { + value := reflect.ValueOf(data) + + result := make([]Identifiable, value.Len()) + for i := 0; i < value.Len(); i++ { + result[i] = value.Index(i).Interface().(Identifiable) + } + return result +} diff --git a/pkg/util/int/int_util.go b/pkg/util/int/int_util.go new file mode 100644 index 000000000..a6338ff85 --- /dev/null +++ b/pkg/util/int/int_util.go @@ -0,0 +1,17 @@ +package int + +// Min returns the minimum of 2 integer arguments. +func Min(a, b int) int { + if a < b { + return a + } + + return b +} + +func Max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/pkg/util/int/int_util_test.go b/pkg/util/int/int_util_test.go new file mode 100644 index 000000000..c5ed17c25 --- /dev/null +++ b/pkg/util/int/int_util_test.go @@ -0,0 +1,20 @@ +package int + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIntMin(t *testing.T) { + var min int + + min = Min(1, 2) + assert.Equal(t, 1, min) + + min = Min(-1, -2) + assert.Equal(t, -2, min) + + min = Min(-2, 10) + assert.Equal(t, -2, min) +} diff --git a/pkg/util/manifest/version.go b/pkg/util/manifest/version.go new file mode 100644 index 000000000..0aad282cb --- /dev/null +++ b/pkg/util/manifest/version.go @@ -0,0 +1,110 @@ +package manifest + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "strings" + + "github.com/10gen/ops-manager-kubernetes/controllers/om" + "github.com/10gen/ops-manager-kubernetes/controllers/om/api" + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/blang/semver" + "go.uber.org/zap" +) + +// Important: 3.6.0 won't work here as semver library will consider 3.6.0-ent as lower than +// 3.6.0 (considers it to be an RC?)! +const MINIMUM_ALLOWED_MDB_VERSION = "3.5.0" + +type Manifest struct { + Updated int `json:"updated"` + Versions []om.MongoDbVersionConfig `json:"versions"` +} + +type Provider interface { + GetVersion() (*Manifest, error) +} + +type FileProvider struct { + FilePath string +} + +func (p FileProvider) GetVersion() (*Manifest, error) { + data, err := ioutil.ReadFile(p.FilePath) + if err != nil { + return nil, err + } + + return readManifest(data) +} + +type InternetProvider struct{} + +func (InternetProvider) GetVersion() (*Manifest, error) { + client, err := api.NewHTTPClient() + if err != nil { + return nil, err + } + resp, err := client.Get(fmt.Sprintf("https://opsmanager.mongodb.com/static/version_manifest/%s.json", util.LatestOmVersion)) + if err != nil { + return nil, err + } + + body, err := ioutil.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, err + } + + return readManifest(body) +} + +// readManifest deserializes and proceses the manifest (filters out legacy versions and fixes links) +func readManifest(data []byte) (*Manifest, error) { + versionManifest := &Manifest{} + err := json.Unmarshal(data, &versionManifest) + if err != nil { + return nil, err + } + + versionManifest.Versions = cutLegacyVersions(versionManifest.Versions, MINIMUM_ALLOWED_MDB_VERSION) + fixLinksAndBuildModules(versionManifest.Versions) + return versionManifest, nil +} + +// cutLegacyVersions filters out the old Mongodb versions from version manifest - otherwise the automation config +// may get too big +func cutLegacyVersions(configs []om.MongoDbVersionConfig, firstAllowedVersion string) []om.MongoDbVersionConfig { + minimumAllowedVersion, _ := semver.Make(firstAllowedVersion) + var versions []om.MongoDbVersionConfig + + for _, version := range configs { + manifestVersion, err := semver.Make(version.Name) + if err != nil { + zap.S().Warnf("Failed to parse version from version manifest: %s", err) + } else if manifestVersion.GE(minimumAllowedVersion) { + versions = append(versions, version) + } + } + return versions +} + +// fixLinksAndBuildModules iterates over build links and prefixes them with a correct domain +// (see mms AutomationMongoDbVersionSvc#buildRemoteUrl) and ensures that build. Modules have +// a non-nil value as this will cause the agent to fail cluster validation +func fixLinksAndBuildModules(configs []om.MongoDbVersionConfig) { + for _, version := range configs { + for _, build := range version.Builds { + if strings.HasSuffix(version.Name, "-ent") { + build.Url = "https://downloads.mongodb.com" + build.Url + } else { + build.Url = "https://fastdl.mongodb.org" + build.Url + } + // AA expects not nil element + if build.Modules == nil { + build.Modules = []string{} + } + } + } +} diff --git a/pkg/util/maputil/mapmerge.go b/pkg/util/maputil/mapmerge.go new file mode 100644 index 000000000..0ee297f6e --- /dev/null +++ b/pkg/util/maputil/mapmerge.go @@ -0,0 +1,63 @@ +package maputil + +import ( + "github.com/spf13/cast" +) + +// MergeMaps is a simplified function to merge one generic map into another recursively. It doesn't use reflection. +// It has some known limitations triggered by use case mainly (as it is used for mongodb options which have quite simple +// structure): +// - slices are not copied recursively +// - pointers are overridden +// +// Dev note: it's used instead of 'mergo' as the latter one is very restrictive in merged types +// (float32 vs float64, string vs string type etc) so works poorly with merging in-memory maps to the unmarshalled ones +// Also it's possible to add visitors functionality for flexible merging if necessary +func MergeMaps(dst, src map[string]interface{}) { + if dst == nil { + return + } + if src == nil { + return + } + for key, srcValue := range src { + switch t := srcValue.(type) { + case map[string]interface{}: + { + if _, ok := dst[key]; !ok { + dst[key] = map[string]interface{}{} + } + // this will fall if the destination value is not map - this is fine + dstMap := dst[key].(map[string]interface{}) + MergeMaps(dstMap, t) + } + default: + { + dst[key] = castValue(dst[key], t) + } + } + } +} + +// Note, that currently slices will not be copied - this is OK for current use cases (mongodb options almost don't +// use arrays). All strings will be copied. +func castValue(dst interface{}, src interface{}) interface{} { + switch dst.(type) { + case int: + return cast.ToInt(src) + case int8: + return cast.ToInt8(src) + case int16: + return cast.ToInt16(src) + case int32: + return cast.ToInt32(src) + case int64: + return cast.ToInt64(src) + case float32: + return cast.ToFloat32(src) + case float64: + return cast.ToFloat64(src) + default: + return src + } +} diff --git a/pkg/util/maputil/mapmerge_test.go b/pkg/util/maputil/mapmerge_test.go new file mode 100644 index 000000000..658e541cf --- /dev/null +++ b/pkg/util/maputil/mapmerge_test.go @@ -0,0 +1,96 @@ +package maputil + +import ( + "testing" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/stretchr/testify/assert" +) + +type SomeType string + +const ( + TypeMongos SomeType = "mongos" +) + +func TestMergeMaps(t *testing.T) { + t.Run("Merge to empty map", func(t *testing.T) { + dst := map[string]interface{}{} + src := mapForTest() + MergeMaps(dst, src) + + assert.Equal(t, dst, src) + + // mutation of the initial map doesn't change the destination + ReadMapValueAsMap(src, "nestedMap")["key4"] = 80 + assert.Equal(t, int32(30), ReadMapValueAsInterface(dst, "nestedMap", "key4")) + }) + t.Run("Merge overrides only common fields", func(t *testing.T) { + dst := map[string]interface{}{ + "key1": "old value", // must be overridden + "key2": map[string]interface{}{"rubbish": "yes"}, // completely different type - will be overridden + "oldkey": "must retain!", + "nestedMap": map[string]interface{}{ + "key4": 100, // the destination type is int - will be cast from int32 + "nestedNestedMap": map[string]interface{}{ + "key7": float32(100.55), // the destination type is float32 - will be cast + "key8": "mongod", // will be overridden by TypeMongos + "key9": []string{"old"}, // must be overridden + "oldkey2": []int{1}, + "anotherNestedMap": map[string]interface{}{}, + }, + }, + } + src := mapForTest() + MergeMaps(dst, src) + + expected := mapForTest() + expected["oldkey"] = "must retain!" + ReadMapValueAsMap(expected, "nestedMap")["key4"] = 30 + ReadMapValueAsMap(expected, "nestedMap", "nestedNestedMap")["key7"] = float32(40.56) + ReadMapValueAsMap(expected, "nestedMap", "nestedNestedMap")["oldkey2"] = []int{1} + ReadMapValueAsMap(expected, "nestedMap", "nestedNestedMap")["anotherNestedMap"] = map[string]interface{}{} + + assert.Equal(t, expected, dst) + + // mutation of the initial map doesn't change the destination + src["nestedMap"].(map[string]interface{})["newkey"] = 80 + assert.Empty(t, ReadMapValueAsInterface(dst, "nestedMap", "newkey")) + }) + t.Run("Fails if destination is not map", func(t *testing.T) { + dst := map[string]interface{}{"nestedMap": "foo"} + src := mapForTest() + assert.Panics(t, func() { MergeMaps(dst, src) }) + }) + t.Run("Pointers are not copied", func(t *testing.T) { + dst := map[string]interface{}{} + src := mapForTest() + MergeMaps(dst, src) + + pointer := ReadMapValueAsInterface(src, "nestedMap", "nestedNestedMap", "key11").(*int32) + *pointer = 20 + + // destination map has changed as well as we don't copy pointers, just reassign them + assert.Equal(t, util.Int32Ref(20), ReadMapValueAsInterface(dst, "nestedMap", "nestedNestedMap", "key11")) + }) +} + +func mapForTest() map[string]interface{} { + return map[string]interface{}{ + "key1": "value1", + "key2": int8(10), + "key3": int16(20), + "nestedMap": map[string]interface{}{ + "key4": int32(30), + "key5": int64(40), + "nestedNestedMap": map[string]interface{}{ + "key6": float32(40.56), + "key7": float64(40.56), + "key8": TypeMongos, + "key9": []string{"one", "two"}, + "key10": true, + "key11": util.Int32Ref(10), + }, + }, + } +} diff --git a/pkg/util/maputil/maputil.go b/pkg/util/maputil/maputil.go new file mode 100644 index 000000000..89737a32c --- /dev/null +++ b/pkg/util/maputil/maputil.go @@ -0,0 +1,138 @@ +package maputil + +import ( + "sort" + "strings" + + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" + "github.com/spf13/cast" +) + +// ReadMapValueAsInterface traverses the nested maps inside the 'm' map following the 'keys' path and returns the last element +// as an 'interface{}' +func ReadMapValueAsInterface(m map[string]interface{}, keys ...string) interface{} { + currentMap := m + for i, k := range keys { + if _, ok := currentMap[k]; !ok { + return nil + } + if i == len(keys)-1 { + return currentMap[k] + } + currentMap = currentMap[k].(map[string]interface{}) + } + return nil +} + +// ReadMapValueAsString traverses the nested maps inside the 'm' map following the 'keys' path and returns the last element +// as a 'string' +func ReadMapValueAsString(m map[string]interface{}, keys ...string) string { + res := ReadMapValueAsInterface(m, keys...) + + if res == nil { + return "" + } + return res.(string) +} + +func ReadMapValueAsInt(m map[string]interface{}, keys ...string) int { + res := ReadMapValueAsInterface(m, keys...) + if res == nil { + return 0 + } + return cast.ToInt(res) +} + +// ReadMapValueAsMap traverses the nested maps inside the 'm' map following the 'keys' path and returns the last element +// as a 'map[string]interface{}' +func ReadMapValueAsMap(m map[string]interface{}, keys ...string) map[string]interface{} { + res := ReadMapValueAsInterface(m, keys...) + + if res == nil { + return nil + } + return res.(map[string]interface{}) +} + +// ToFlatList returns all elements as a sorted list of string values. +// It performs a recursive traversal of maps and dumps the current config to the final list of configs +func ToFlatList(m map[string]interface{}) []string { + result := traverse(m, []string{}) + sort.Strings(result) + return result +} + +// SetMapValue traverses the nested maps inside the 'm' map following the 'keys' path and sets the value 'value' to the +// final key. The key -> nested map entry will be created if doesn't exist +func SetMapValue(m map[string]interface{}, value interface{}, keys ...string) { + current := m + for _, k := range keys[0 : len(keys)-1] { + if _, ok := current[k]; !ok { + current[k] = map[string]interface{}{} + } + current = current[k].(map[string]interface{}) + } + last := keys[len(keys)-1] + current[last] = value +} + +func DeleteMapValue(m map[string]interface{}, keys ...string) { + current := m + for _, k := range keys[0 : len(keys)-1] { + if _, ok := current[k]; !ok { + current[k] = map[string]interface{}{} + } + current = current[k].(map[string]interface{}) + } + delete(current, keys[len(keys)-1]) +} + +func traverse(currentValue interface{}, currentPath []string) []string { + switch v := currentValue.(type) { + case map[string]interface{}: + { + var allPaths []string + for key, value := range v { + allPaths = append(allPaths, traverse(value, append(currentPath, key))...) + } + return allPaths + } + default: + { + // We found the "terminal" node in the map - need to dump the current path + path := strings.Join(currentPath, ".") + return []string{path} + } + } +} + +// RemoveFieldsBasedOnDesiredAndPrevious returns a "currentMap" that has had fields removed based on what was in the previousMap +// and what is in the desiredMap. Any values that were there previously, but are no longer desired, will be removed and the +// resulting map will not contain them. +func RemoveFieldsBasedOnDesiredAndPrevious(currentMap, desiredMap, previousMap map[string]interface{}) map[string]interface{} { + if desiredMap == nil { + desiredMap = map[string]interface{}{} + } + + if previousMap == nil { + previousMap = map[string]interface{}{} + } + + desiredFlatList := ToFlatList(desiredMap) + previousFlatList := ToFlatList(previousMap) + + var itemsToRemove []string + for _, item := range previousFlatList { + // if an item was set previously, but is not set now + // it means we want to remove this item. + if !stringutil.Contains(desiredFlatList, item) { + itemsToRemove = append(itemsToRemove, item) + } + } + + for _, item := range itemsToRemove { + DeleteMapValue(currentMap, strings.Split(item, ".")...) + } + + return currentMap +} diff --git a/pkg/util/maputil/maputil_test.go b/pkg/util/maputil/maputil_test.go new file mode 100644 index 000000000..b97f45d7d --- /dev/null +++ b/pkg/util/maputil/maputil_test.go @@ -0,0 +1,81 @@ +package maputil + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSetMapValue(t *testing.T) { + t.Run("Set to empty map", func(t *testing.T) { + dest := map[string]interface{}{} + SetMapValue(dest, 30, "one", "two", "three") + expectedMap := map[string]interface{}{ + "one": map[string]interface{}{ + "two": map[string]interface{}{ + "three": 30, + }, + }, + } + assert.Equal(t, expectedMap, dest) + }) + t.Run("Set to non-empty map", func(t *testing.T) { + dest := map[string]interface{}{ + "one": map[string]interface{}{ + "ten": "bar", + "two": map[string]interface{}{ + "three": 100, + "eleven": true, + }, + }, + } + SetMapValue(dest, 30, "one", "two", "three") + expectedMap := map[string]interface{}{ + "one": map[string]interface{}{ + "ten": "bar", + "two": map[string]interface{}{ + "three": 30, // this was changed + "eleven": true, + }, + }, + } + assert.Equal(t, expectedMap, dest) + }) + +} + +func TestRemoveFieldsBasedOnDesiredAndPrevious(t *testing.T) { + p := map[string]interface{}{ + "one": "oneValue", + "two": map[string]interface{}{ + "three": "threeValue", + "four": "fourValue", + }, + } + + // we are removing the "two.three" entry in this case. + spec := map[string]interface{}{ + "one": "oneValue", + "two": map[string]interface{}{ + "four": "fourValue", + }, + } + + prev := map[string]interface{}{ + "one": "oneValue", + "two": map[string]interface{}{ + "three": "threeValue", + "four": "fourValue", + }, + } + + expected := map[string]interface{}{ + "one": "oneValue", + "two": map[string]interface{}{ + "four": "fourValue", + }, + } + + actual := RemoveFieldsBasedOnDesiredAndPrevious(p, spec, prev) + assert.Equal(t, expected, actual, "three was set previously, and so should have been removed.") +} diff --git a/pkg/util/mergo_utils.go b/pkg/util/mergo_utils.go new file mode 100644 index 000000000..127133e60 --- /dev/null +++ b/pkg/util/mergo_utils.go @@ -0,0 +1,119 @@ +package util + +import ( + "encoding/json" + "reflect" + + "github.com/spf13/cast" + + "github.com/imdario/mergo" +) + +// MergoDelete is a sentinel value that indicates a field is to be removed during the merging process +const MergoDelete = "MERGO_DELETE" + +// AutomationConfigTransformer when we want to delete the last element of a list, if we use the +// default behaviour we will still be left with the final element from the original map. Using this +// Transformer allows us to override that behaviour and perform the merging as we expect. +type AutomationConfigTransformer struct{} + +func isStringMap(elem interface{}) bool { + return reflect.TypeOf(elem) == reflect.TypeOf(make(map[string]interface{})) +} + +// withoutElementAtIndex returns the given slice without the element at the specified index +func withoutElementAtIndex(slice []interface{}, index int) []interface{} { + return append(slice[:index], slice[index+1:]...) // slice[i+1:] returns an empty slice if i >= len(slice) +} + +// mergeBoth is called when both maps have a common field +func mergeBoth(structAsMap map[string]interface{}, unmodifiedOriginalMap map[string]interface{}, key string, val interface{}) { + switch val.(type) { + case map[string]interface{}: + // we already know about the key, and it's a nested map so we can continue + merge(cast.ToStringMap(structAsMap[key]), cast.ToStringMap(unmodifiedOriginalMap[key])) + case []interface{}: + i, j := 0, 0 + for _, element := range cast.ToSlice(val) { + elementsFromStruct := cast.ToSlice(structAsMap[key]) + + if i >= len(elementsFromStruct) { + break + } + + // in the case of a nested map, we can continue the merging process + if isStringMap(element) { + // by marking an element as nil, we indicate that we want to delete this element + if cast.ToSlice(structAsMap[key])[i] == nil { + slice := cast.ToSlice(structAsMap[key]) + structAsMap[key] = withoutElementAtIndex(slice, i) + i-- // if we removed the element at a given position, we want to examine the same index again as the contents have shifted + } else { + merge(cast.ToStringMap(cast.ToSlice(structAsMap[key])[i]), cast.ToStringMap(cast.ToSlice(unmodifiedOriginalMap[key])[j])) + } + } + // we need to maintain 2 counters in order to prevent merging a map from "structAsMap" with a value from "unmodifiedOriginalMap" + // that doesn't correspond to the same logical value. + i++ + j++ + } + // for any other type, the value has been set by the operator, so we don't want to override + // a value from the existing Automation Config value in that case. + } +} + +// merge takes a map dst (serialized from a struct) and a map src (the map from an unmodified deployment) +// and merges them together based on a set of rules +func merge(structAsMap, unmodifiedOriginalMap map[string]interface{}) { + for key, val := range unmodifiedOriginalMap { + if _, ok := structAsMap[key]; !ok { + switch val.(type) { + case []interface{}: + structAsMap[key] = make([]interface{}, 0) + default: + // if we don't know about this value, then we can just accept the value coming from the Automation Config + structAsMap[key] = val + } + + } else { // the value exists already in the map we have, we need to perform merge + mergeBoth(structAsMap, unmodifiedOriginalMap, key, val) + } + } + + // Delete any fields marked with "util.MergoDelete" + for key, val := range structAsMap { + // if we're explicitly sending a value of nil, it means we want to delete the corresponding entry. + // We don't want to ever send nil values. + if val == MergoDelete || val == nil { + delete(structAsMap, key) + } + } +} + +func (t AutomationConfigTransformer) Transformer(reflect.Type) func(dst, src reflect.Value) error { + return func(dst, src reflect.Value) error { + dstMap := cast.ToStringMap(dst.Interface()) + srcMap := cast.ToStringMap(src.Interface()) + merge(dstMap, srcMap) + return nil + } +} + +// MergeWith takes a structToMerge, a source map src, and returns the result of the merging, and an error +func MergeWith(structToMerge interface{}, src map[string]interface{}, transformers mergo.Transformers) (map[string]interface{}, error) { + bytes, err := json.Marshal(structToMerge) + if err != nil { + return nil, err + } + dst := make(map[string]interface{}) + err = json.Unmarshal(bytes, &dst) + if err != nil { + return nil, err + } + + if err := mergo.Merge(&dst, src, mergo.WithTransformers(transformers)); err != nil { + return nil, err + } + + return dst, nil +} diff --git a/pkg/util/stringutil/stringutil.go b/pkg/util/stringutil/stringutil.go new file mode 100644 index 000000000..b5375c285 --- /dev/null +++ b/pkg/util/stringutil/stringutil.go @@ -0,0 +1,93 @@ +package stringutil + +import "strings" + +// Ref is a convenience function which returns +// a reference to the provided string +func Ref(s string) *string { + return &s +} + +// Contains returns true if there is at least one string in `slice` +// that is equal to `s`. +func Contains(slice []string, s string) bool { + for _, item := range slice { + if item == s { + return true + } + } + return false +} + +// CheckCertificateAddresses determines if the provided FQDN can match any of the addresses or +// SubjectAltNames (SAN) in an array of FQDNs/wildcards/shortnames. +// Both the availableAddressNames and the testAddressName can contain wildcards, e.g. *.cluster-1.example.com +// Once a wildcard is found on any tested argument, only a domain-level comparison is checked. +func CheckCertificateAddresses(availableAddressNames []string, testAddressName string) bool { + checkedTestAddressName := CheckWithLevelDomain(testAddressName) + star := "*" + for _, availableAddress := range availableAddressNames { + // Determine if the certificate name is a wildcard, FQDN, unqualified domain name, or shortname + // Strip the first character from the wildcard and hostname to determine if they match + // (wildcards only work for one level of domain) + if availableAddress[0:1] == star { + checkAddress := CheckWithLevelDomain(availableAddress) + if checkAddress == checkedTestAddressName { + return true + } + } + if availableAddress == testAddressName { + return true + } + // This is the multi-cluster with an external domain case. + // We do not want to deal if this is per-member cert or a wildcard, that's why we will only + // compare the domains. + if testAddressName[0:1] == star { + domainOnlyTestAddress := CheckWithLevelDomain(testAddressName) + domainOnlyAvailableAddress := CheckWithLevelDomain(availableAddress) + + if domainOnlyAvailableAddress == domainOnlyTestAddress { + return true + } + } + } + return false +} + +// CheckWithLevelDomain determines if the address is a shortname/top level domain +// or FQDN/Unqualified Domain Name +func CheckWithLevelDomain(address string) string { + addressExploded := strings.Split(address, ".") + if len(addressExploded) < 2 { + return addressExploded[0] + } + return strings.Join(addressExploded[1:], ".") +} + +func ContainsAny(slice []string, ss ...string) bool { + for _, s := range ss { + if Contains(slice, s) { + return true + } + } + + return false +} + +func Remove(slice []string, s string) (result []string) { + for _, item := range slice { + if item == s { + continue + } + result = append(result, item) + } + return +} + +// UpperCaseFirstChar ensures the message first char is uppercased. +func UpperCaseFirstChar(msg string) string { + if msg == "" { + return "" + } + return strings.ToUpper(msg[:1]) + msg[1:] +} diff --git a/pkg/util/stringutil/stringutil_test.go b/pkg/util/stringutil/stringutil_test.go new file mode 100644 index 000000000..1ce59a5d0 --- /dev/null +++ b/pkg/util/stringutil/stringutil_test.go @@ -0,0 +1,28 @@ +package stringutil + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestContainsAny(t *testing.T) { + assert.True(t, ContainsAny([]string{"one", "two"}, "one")) + assert.True(t, ContainsAny([]string{"one", "two"}, "two")) + assert.True(t, ContainsAny([]string{"one", "two"}, "one", "two")) + assert.True(t, ContainsAny([]string{"one", "two"}, "one", "two", "three")) + + assert.False(t, ContainsAny([]string{"one", "two"}, "three")) + assert.False(t, ContainsAny([]string{"one", "two"})) +} + +func TestCheckCertificateDomains(t *testing.T) { + assert.True(t, CheckCertificateAddresses([]string{"abd.efg.com", "*.cluster.local", "*.dev.local", "abc.mongodb.com"}, "abc.cluster.local")) + assert.True(t, CheckCertificateAddresses([]string{"abd.efg.com", "*.cluster.local", "*.dev.local", "abc.mongodb.com"}, "*.cluster.local")) + assert.True(t, CheckCertificateAddresses([]string{"abd.efg.com", "*.cluster.local", "*.dev.local", "abc.mongodb.com"}, "abd.efg.com")) + assert.True(t, CheckCertificateAddresses([]string{"abd.efg.com", "*.cluster.local", "*.dev.local", "abc.mongodb.com", "abcdefg"}, "abcdefg")) + + assert.False(t, CheckCertificateAddresses([]string{"abd.efg.com", "*.cluster.local", "*.dev.local", "abc.mongodb.com"}, "abc.efg.com")) + assert.False(t, CheckCertificateAddresses([]string{"abd.efg.com", "*.cluster.local", "*.dev.local", "abc.mongodb.com", "abcdef"}, "abdcdefg")) + assert.False(t, CheckCertificateAddresses([]string{"abd.efg.com", "*.cluster.local", "*.dev.local", "abc.mongodb.com", "abcdef"}, "*.somethingthatdoesntfit")) +} diff --git a/pkg/util/timeutil/timeutil.go b/pkg/util/timeutil/timeutil.go new file mode 100644 index 000000000..5b4ceaf2a --- /dev/null +++ b/pkg/util/timeutil/timeutil.go @@ -0,0 +1,8 @@ +package timeutil + +import "time" + +// Now returns the current time formatted with RFC3339 +func Now() string { + return time.Now().Format(time.RFC3339) +} diff --git a/pkg/util/util.go b/pkg/util/util.go new file mode 100644 index 000000000..6f87ebacb --- /dev/null +++ b/pkg/util/util.go @@ -0,0 +1,179 @@ +package util + +import ( + "encoding/hex" + "regexp" + "strings" + "time" + + "github.com/10gen/ops-manager-kubernetes/pkg/util/stringutil" + + "bytes" + "encoding/gob" + + "crypto/md5" + "fmt" + + "github.com/blang/semver" + "go.uber.org/zap" +) + +// ************** This is a file containing any general "algorithmic" or "util" functions used by other packages + +// FindLeftDifference finds the difference between arrays of string - the elements that are present in "left" but absent +// in "right" array +func FindLeftDifference(left, right []string) []string { + ans := make([]string, 0) + for _, v := range left { + if !stringutil.Contains(right, v) { + ans = append(ans, v) + } + } + return ans +} + +// Int32Ref is required to return a *int32, which can't be declared as a literal. +func Int32Ref(i int32) *int32 { + return &i +} + +// Int64Ref is required to return a *int64, which can't be declared as a literal. +func Int64Ref(i int64) *int64 { + return &i +} + +// Float64Ref is required to return a *float64, which can't be declared as a literal. +func Float64Ref(i float64) *float64 { + return &i +} + +// BooleanRef is required to return a *bool, which can't be declared as a literal. +func BooleanRef(b bool) *bool { + return &b +} + +func StripEnt(version string) string { + return strings.Trim(version, "-ent") +} + +// DoAndRetry performs the task 'f' until it returns true or 'count' retrials are executed. Sleeps for 'interval' seconds +// between retries. String return parameter contains the fail message that is printed in case of failure. +func DoAndRetry(f func() (string, bool), log *zap.SugaredLogger, count, interval int) bool { + for i := 0; i < count; i++ { + msg, ok := f() + if ok { + return true + } + if msg != "" { + msg += "." + } + log.Debugf("%s Retrying %d/%d (waiting for %d more seconds)", msg, i+1, count, interval) + time.Sleep(time.Duration(interval) * time.Second) + } + return false +} + +// MapDeepCopy is a quick implementation of deep copy mechanism for any Go structures, it uses Go serialization and +// deserialization mechanisms so will always be slower than any manual copy +// https://rosettacode.org/wiki/Deepcopy#Go +// TODO move to maputil +func MapDeepCopy(m map[string]interface{}) (map[string]interface{}, error) { + gob.Register(map[string]interface{}{}) + + var buf bytes.Buffer + enc := gob.NewEncoder(&buf) + dec := gob.NewDecoder(&buf) + err := enc.Encode(m) + if err != nil { + return nil, err + } + var copy map[string]interface{} + err = dec.Decode(©) + if err != nil { + return nil, err + } + return copy, nil +} + +func ReadOrCreateMap(m map[string]interface{}, key string) map[string]interface{} { + if _, ok := m[key]; !ok { + m[key] = make(map[string]interface{}) + } + return m[key].(map[string]interface{}) +} + +func ReadOrCreateStringArray(m map[string]interface{}, key string) []string { + if _, ok := m[key]; !ok { + m[key] = make([]string, 0) + } + return m[key].([]string) +} + +func CompareVersions(version1, version2 string) (int, error) { + v1, err := semver.Make(version1) + if err != nil { + return 0, err + } + v2, err := semver.Make(version2) + if err != nil { + return 0, err + } + return v1.Compare(v2), nil +} + +func VersionMatchesRange(version, vRange string) (bool, error) { + v, err := semver.Parse(version) + if err != nil { + return false, err + } + expectedRange, err := semver.ParseRange(vRange) + if err != nil { + return false, err + } + return expectedRange(v), nil +} + +func MajorMinorVersion(version string) (string, error) { + v1, err := semver.Make(version) + if err != nil { + return "", nil + } + return fmt.Sprintf("%d.%d", v1.Major, v1.Minor), nil +} + +// ************ Different functions to work with environment variables ************** + +func MaxInt(x, y int) int { + if x > y { + return x + } + return y +} + +// ************ Different string/array functions ************** +// +// Helper functions to check and remove string from a slice of strings. +// + +// MD5Hex computes the MDB checksum of the given string as per https://golang.org/pkg/crypto/md5/ +func MD5Hex(s string) string { + h := md5.New() + h.Write([]byte(s)) + return hex.EncodeToString(h.Sum(nil)) +} + +// RedactMongoURI will strip the password out of the MongoURI and replace it with the text "" +func RedactMongoURI(uri string) string { + if !strings.Contains(uri, "@") { + return uri + } + re := regexp.MustCompile("(mongodb://.*:)(.*)(@.*:.*)") + return re.ReplaceAllString(uri, "$1$3") +} + +func Redact(toRedact interface{}) string { + if toRedact == nil { + return "nil" + } + return "" +} diff --git a/pkg/util/util_test.go b/pkg/util/util_test.go new file mode 100644 index 000000000..6784285f7 --- /dev/null +++ b/pkg/util/util_test.go @@ -0,0 +1,196 @@ +package util + +import ( + "testing" + + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" + "github.com/10gen/ops-manager-kubernetes/pkg/util/identifiable" + + "os" + + "github.com/stretchr/testify/assert" +) + +func TestCompareVersions(t *testing.T) { + i, e := CompareVersions("4.0.5", "4.0.4") + assert.NoError(t, e) + assert.Equal(t, 1, i) + + i, e = CompareVersions("4.0.0", "4.0.0") + assert.NoError(t, e) + assert.Equal(t, 0, i) + + i, e = CompareVersions("3.6.15", "4.1.0") + assert.NoError(t, e) + assert.Equal(t, -1, i) + + i, e = CompareVersions("3.6.2", "3.6.12") + assert.NoError(t, e) + assert.Equal(t, -1, i) + + i, e = CompareVersions("4.0.2-ent", "4.0.1") + assert.NoError(t, e) + assert.Equal(t, 1, i) +} + +func TestMajorMinorVersion(t *testing.T) { + s, e := MajorMinorVersion("3.6.12") + assert.NoError(t, e) + assert.Equal(t, "3.6", s) + + s, e = MajorMinorVersion("4.0.0") + assert.NoError(t, e) + assert.Equal(t, "4.0", s) + + s, e = MajorMinorVersion("4.2.12-ent") + assert.NoError(t, e) + assert.Equal(t, "4.2", s) +} + +func TestReadBoolEnv(t *testing.T) { + os.Setenv("ENV_1", "true") + os.Setenv("ENV_2", "false") + os.Setenv("ENV_3", "TRUE") + os.Setenv("NOT_BOOL", "not-true") + + result, present := env.ReadBool("ENV_1") + assert.True(t, present) + assert.True(t, result) + + result, present = env.ReadBool("ENV_2") + assert.True(t, present) + assert.False(t, result) + + result, present = env.ReadBool("ENV_3") + assert.True(t, present) + assert.True(t, result) + + result, present = env.ReadBool("NOT_BOOL") + assert.False(t, present) + assert.False(t, result) + + result, present = env.ReadBool("NOT_HERE") + assert.False(t, present) + assert.False(t, result) +} + +func TestRedactURI(t *testing.T) { + uri := "mongo.mongoUri=mongodb://mongodb-ops-manager:my-scram-password@om-scram-db-0.om-scram-db-svc.mongodb.svc.cluster.local:27017/?connectTimeoutMS=20000&serverSelectionTimeoutMS=20000&authSource=admin&authMechanism=SCRAM-SHA-1" + expected := "mongo.mongoUri=mongodb://mongodb-ops-manager:@om-scram-db-0.om-scram-db-svc.mongodb.svc.cluster.local:27017/?connectTimeoutMS=20000&serverSelectionTimeoutMS=20000&authSource=admin&authMechanism=SCRAM-SHA-1" + assert.Equal(t, expected, RedactMongoURI(uri)) + + uri = "mongo.mongoUri=mongodb://mongodb-ops-manager:mongodb-ops-manager@om-scram-db-0.om-scram-db-svc.mongodb.svc.cluster.local:27017/?connectTimeoutMS=20000&serverSelectionTimeoutMS=20000" + expected = "mongo.mongoUri=mongodb://mongodb-ops-manager:@om-scram-db-0.om-scram-db-svc.mongodb.svc.cluster.local:27017/?connectTimeoutMS=20000&serverSelectionTimeoutMS=20000" + assert.Equal(t, expected, RedactMongoURI(uri)) + + // the password with '@' in it + uri = "mongo.mongoUri=mongodb://some-user:12345AllTheCharactersWith@SymbolToo@om-scram-db-0.om-scram-db-svc.mongodb.svc.cluster.local:27017" + expected = "mongo.mongoUri=mongodb://some-user:@om-scram-db-0.om-scram-db-svc.mongodb.svc.cluster.local:27017" + assert.Equal(t, expected, RedactMongoURI(uri)) + + // no authentication data + uri = "mongo.mongoUri=mongodb://om-scram-db-0.om-scram-db-svc.mongodb.svc.cluster.local:27017" + expected = "mongo.mongoUri=mongodb://om-scram-db-0.om-scram-db-svc.mongodb.svc.cluster.local:27017" + assert.Equal(t, expected, RedactMongoURI(uri)) +} + +type someId struct { + // name is a "key" field used for merging + name string + // some other property. Indicates which exactly object was returned by an aggregation operation + property string +} + +func newSome(name, property string) someId { + return someId{ + name: name, + property: property, + } +} + +func (s someId) Identifier() interface{} { + return s.name +} + +func TestSetDifference(t *testing.T) { + oneLeft := newSome("1", "left") + twoLeft := newSome("2", "left") + twoRight := newSome("2", "right") + threeRight := newSome("3", "right") + fourRight := newSome("4", "right") + + left := []identifiable.Identifiable{oneLeft, twoLeft} + right := []identifiable.Identifiable{twoRight, threeRight} + + assert.Equal(t, []identifiable.Identifiable{oneLeft}, identifiable.SetDifference(left, right)) + assert.Equal(t, []identifiable.Identifiable{threeRight}, identifiable.SetDifference(right, left)) + + left = []identifiable.Identifiable{oneLeft, twoLeft} + right = []identifiable.Identifiable{threeRight, fourRight} + assert.Equal(t, left, identifiable.SetDifference(left, right)) + + left = []identifiable.Identifiable{} + right = []identifiable.Identifiable{threeRight, fourRight} + assert.Empty(t, identifiable.SetDifference(left, right)) + assert.Equal(t, right, identifiable.SetDifference(right, left)) + + left = nil + right = []identifiable.Identifiable{threeRight, fourRight} + assert.Empty(t, identifiable.SetDifference(left, right)) + assert.Equal(t, right, identifiable.SetDifference(right, left)) + + // check reflection magic to solve lack of covariance in go. The arrays are declared as '[]someId' instead of + // '[]Identifiable' + leftNotIdentifiable := []someId{oneLeft, twoLeft} + rightNotIdentifiable := []someId{twoRight, threeRight} + + assert.Equal(t, []identifiable.Identifiable{oneLeft}, identifiable.SetDifferenceGeneric(leftNotIdentifiable, rightNotIdentifiable)) + assert.Equal(t, []identifiable.Identifiable{threeRight}, identifiable.SetDifferenceGeneric(rightNotIdentifiable, leftNotIdentifiable)) +} + +func TestSetIntersection(t *testing.T) { + oneLeft := newSome("1", "left") + oneRight := newSome("1", "right") + twoLeft := newSome("2", "left") + twoRight := newSome("2", "right") + threeRight := newSome("3", "right") + fourRight := newSome("4", "right") + + left := []identifiable.Identifiable{oneLeft, twoLeft} + right := []identifiable.Identifiable{twoRight, threeRight} + + assert.Equal(t, [][]identifiable.Identifiable{pair(twoLeft, twoRight)}, identifiable.SetIntersection(left, right)) + assert.Equal(t, [][]identifiable.Identifiable{pair(twoRight, twoLeft)}, identifiable.SetIntersection(right, left)) + + left = []identifiable.Identifiable{oneLeft, twoLeft} + right = []identifiable.Identifiable{threeRight, fourRight} + assert.Empty(t, identifiable.SetIntersection(left, right)) + assert.Empty(t, identifiable.SetIntersection(right, left)) + + left = []identifiable.Identifiable{} + right = []identifiable.Identifiable{threeRight, fourRight} + assert.Empty(t, identifiable.SetIntersection(left, right)) + assert.Empty(t, identifiable.SetIntersection(right, left)) + + left = nil + right = []identifiable.Identifiable{threeRight, fourRight} + assert.Empty(t, identifiable.SetIntersection(left, right)) + assert.Empty(t, identifiable.SetIntersection(right, left)) + + // check reflection magic to solve lack of covariance in go. The arrays are declared as '[]someId' instead of + // '[]Identifiable' + leftNotIdentifiable := []someId{oneLeft, twoLeft} + rightNotIdentifiable := []someId{oneRight, twoRight, threeRight} + + assert.Equal(t, [][]identifiable.Identifiable{pair(oneLeft, oneRight), pair(twoLeft, twoRight)}, identifiable.SetIntersectionGeneric(leftNotIdentifiable, rightNotIdentifiable)) + assert.Equal(t, [][]identifiable.Identifiable{pair(oneRight, oneLeft), pair(twoRight, twoLeft)}, identifiable.SetIntersectionGeneric(rightNotIdentifiable, leftNotIdentifiable)) + + leftNotIdentifiable = []someId{oneLeft, twoLeft} + rightNotIdentifiable = []someId{oneLeft, twoLeft} + + assert.Len(t, identifiable.SetIntersectionGeneric(leftNotIdentifiable, rightNotIdentifiable), 0) +} + +func pair(left, right identifiable.Identifiable) []identifiable.Identifiable { + return []identifiable.Identifiable{left, right} +} diff --git a/pkg/util/versionutil/versionutil.go b/pkg/util/versionutil/versionutil.go new file mode 100644 index 000000000..56a100b4a --- /dev/null +++ b/pkg/util/versionutil/versionutil.go @@ -0,0 +1,83 @@ +package versionutil + +import ( + "regexp" + "strings" + + "github.com/blang/semver" + "golang.org/x/xerrors" +) + +var semverRegex *regexp.Regexp + +// StringToSemverVersion returns semver.Version for the 'version' provided as a string. +// Important: this method is a bit hacky as ignores everything after patch and must be used only when needed +// (so far only for creating the semver for OM version as this was needed to support IBM) +func StringToSemverVersion(version string) (semver.Version, error) { + v, err := semver.Make(version) + if err != nil { + // Regex adapted from https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string + // but removing the parts after the patch + if semverRegex == nil { + semverRegex = regexp.MustCompile(`^(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)?(-|$)`) + } + result := semverRegex.FindStringSubmatch(version) + if result == nil || len(result) < 4 { + return semver.Version{}, xerrors.Errorf("Ops Manager Status spec.version %s is invalid", version) + } + // Concatenate Major.Minor.Patch + v, err = semver.Make(result[1] + "." + result[2] + "." + result[3]) + } + return v, err +} + +type OpsManagerVersion struct { + VersionString string +} + +func (v OpsManagerVersion) Semver() (semver.Version, error) { + if v.IsCloudManager() { + return semver.Version{}, nil + } + + versionParts := strings.Split(v.VersionString, ".") // [4 2 4 56729 20191105T2247Z] + if len(versionParts) < 3 { + return semver.Version{}, nil + } + + sv, err := semver.Make(strings.Join(versionParts[:3], ".")) + if err != nil { + return semver.Version{}, err + } + + return sv, nil +} + +func (v OpsManagerVersion) IsCloudManager() bool { + return strings.HasPrefix(strings.ToLower(v.VersionString), "v") +} + +func (v OpsManagerVersion) IsUnknown() bool { + return v.VersionString == "" +} + +func (v OpsManagerVersion) String() string { + return v.VersionString +} + +// GetVersionFromOpsManagerApiHeader returns the major, minor and patch version from the string +// which is returned in the header of all Ops Manager responses in the form of: +// gitHash=f7bdac406b7beceb1415fd32c81fc64501b6e031; versionString=4.2.4.56729.20191105T2247Z +func GetVersionFromOpsManagerApiHeader(versionString string) string { + if versionString == "" || !strings.Contains(versionString, "versionString=") { + return "" + } + + splitString := strings.Split(versionString, "versionString=") + + if len(splitString) == 2 { + return splitString[1] + } + + return "" +} diff --git a/pkg/util/versionutil/versionutil_test.go b/pkg/util/versionutil/versionutil_test.go new file mode 100644 index 000000000..6f35c12f2 --- /dev/null +++ b/pkg/util/versionutil/versionutil_test.go @@ -0,0 +1,18 @@ +package versionutil + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetVersionString(t *testing.T) { + assert.Equal(t, "4.2.4.56729.20191105T2247Z", + GetVersionFromOpsManagerApiHeader("gitHash=f7bdac406b7beceb1415fd32c81fc64501b6e031; versionString=4.2.4.56729.20191105T2247Z")) + assert.Equal(t, "4.4.41.12345.20191105T2247Z", + GetVersionFromOpsManagerApiHeader("gitHash=f7bdac406b7beceb1415fd32c81fc64501b6e031; versionString=4.4.41.12345.20191105T2247Z")) + assert.Equal(t, "4.3.0.56729.DEFXYZ", + GetVersionFromOpsManagerApiHeader("gitHash=f7bdac406b7beceb1415fd32c81fc64501b6e031; versionString=4.3.0.56729.DEFXYZ")) + assert.Equal(t, "31.24.55.202056729.ABCXYZ", + GetVersionFromOpsManagerApiHeader("gitHash=f7bdac406b7beceb1415fd32c81fc64501b6e031; versionString=31.24.55.202056729.ABCXYZ")) +} diff --git a/pkg/vault/vault.go b/pkg/vault/vault.go new file mode 100644 index 000000000..cad9d6c5a --- /dev/null +++ b/pkg/vault/vault.go @@ -0,0 +1,545 @@ +package vault + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "os" + "strconv" + "strings" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" + "github.com/10gen/ops-manager-kubernetes/pkg/util/env" + "github.com/10gen/ops-manager-kubernetes/pkg/util/maputil" + "github.com/hashicorp/vault/api" + "github.com/mongodb/mongodb-kubernetes-operator/pkg/util/merge" + "golang.org/x/xerrors" + corev1 "k8s.io/api/core/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +const ( + VaultBackend = "VAULT_BACKEND" + K8sSecretBackend = "K8S_SECRET_BACKEND" + + DEFAULT_OPERATOR_SECRET_PATH = "mongodbenterprise/operator" + DEFAULT_OPS_MANAGER_SECRET_PATH = "mongodbenterprise/opsmanager" + DEFAULT_DATABASE_SECRET_PATH = "mongodbenterprise/database" + DEFAULT_APPDB_SECRET_PATH = "mongodbenterprise/appdb" + + DEFAULT_VAULT_ADDRESS = "vault.vault.svc.cluster.local" + DEFAULT_VAULT_PORT = "8200" + + DatabaseVaultRoleName = "mongodbenterprisedatabase" + OpsManagerVaultRoleName = "mongodbenterpriseopsmanager" + AppDBVaultRoleName = "mongodbenterpriseappdb" + + VAULT_SERVER_ADDRESS = "VAULT_SERVER_ADDRESS" + OPERATOR_SECRET_BASE_PATH = "OPERATOR_SECRET_BASE_PATH" + TLS_SECRET_REF = "TLS_SECRET_REF" + OPS_MANAGER_SECRET_BASE_PATH = "OPS_MANAGER_SECRET_BASE_PATH" + DATABASE_SECRET_BASE_PATH = "DATABASE_SECRET_BASE_PATH" + APPDB_SECRET_BASE_PATH = "APPDB_SECRET_BASE_PATH" +) + +type DatabaseSecretsToInject struct { + AgentCerts string + AgentApiKey string + InternalClusterAuth string + InternalClusterHash string + MemberClusterAuth string + MemberClusterHash string + Config VaultConfiguration + Prometheus string + PrometheusTLSCertHash string +} + +type AppDBSecretsToInject struct { + AgentApiKey string + TLSSecretName string + TLSClusterHash string + + AutomationConfigSecretName string + AutomationConfigPath string + AgentType string + Config VaultConfiguration + + PrometheusTLSCertHash string + PrometheusTLSPath string +} + +type OpsManagerSecretsToInject struct { + TLSSecretName string + TLSHash string + GenKeyPath string + AppDBConnection string + AppDBConnectionVolume string + Config VaultConfiguration +} + +func IsVaultSecretBackend() bool { + return os.Getenv("SECRET_BACKEND") == VaultBackend +} + +type VaultConfiguration struct { + OperatorSecretPath string + DatabaseSecretPath string + OpsManagerSecretPath string + AppDBSecretPath string + VaultAddress string + TLSSecretRef string +} + +type VaultClient struct { + client *api.Client + VaultConfig VaultConfiguration +} + +func readVaultConfig(client *kubernetes.Clientset) VaultConfiguration { + cm, err := client.CoreV1().ConfigMaps(env.ReadOrPanic(util.CurrentNamespace)).Get(context.TODO(), "secret-configuration", v1.GetOptions{}) + if err != nil { + panic(xerrors.Errorf("error reading vault configmap: %w", err)) + } + + config := VaultConfiguration{ + OperatorSecretPath: cm.Data[OPERATOR_SECRET_BASE_PATH], + VaultAddress: cm.Data[VAULT_SERVER_ADDRESS], + OpsManagerSecretPath: cm.Data[OPS_MANAGER_SECRET_BASE_PATH], + DatabaseSecretPath: cm.Data[DATABASE_SECRET_BASE_PATH], + AppDBSecretPath: cm.Data[APPDB_SECRET_BASE_PATH], + } + + if tlsRef, ok := cm.Data[TLS_SECRET_REF]; ok { + config.TLSSecretRef = tlsRef + } + + return config +} + +func setTLSConfig(config *api.Config, client *kubernetes.Clientset, tlsSecretRef string) error { + if tlsSecretRef == "" { + return nil + } + var secret *corev1.Secret + var err error + secret, err = client.CoreV1().Secrets(env.ReadOrPanic(util.CurrentNamespace)).Get(context.TODO(), tlsSecretRef, v1.GetOptions{}) + + if err != nil { + return xerrors.Errorf("can't read tls secret %s for vault: %w", tlsSecretRef, err) + } + + // Read the secret and write ca.crt to a temporary file + caData := secret.Data["ca.crt"] + f, err := ioutil.TempFile("/tmp", "VaultCAData") + if err != nil { + return xerrors.Errorf("can't create temporary file for CA data: %w", err) + } + defer f.Close() + + _, err = f.Write(caData) + if err != nil { + return xerrors.Errorf("can't write caData to file %s: %w", f.Name(), err) + } + if err = f.Sync(); err != nil { + return xerrors.Errorf("can't call Sync on file %s: %w", f.Name(), err) + + } + + config.ConfigureTLS( + &api.TLSConfig{ + CACert: f.Name(), + }, + ) + + return nil + +} + +func InitVaultClient(client *kubernetes.Clientset) (*VaultClient, error) { + vaultConfig := readVaultConfig(client) + + config := api.DefaultConfig() + config.Address = vaultConfig.VaultAddress + + if err := setTLSConfig(config, client, vaultConfig.TLSSecretRef); err != nil { + return nil, err + } + + vclient, err := api.NewClient(config) + + if err != nil { + return nil, err + } + + return &VaultClient{client: vclient, VaultConfig: vaultConfig}, nil +} + +func (v *VaultClient) Login() error { + // Read the service-account token from the path where the token's Kubernetes Secret is mounted. + jwt, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/token") + if err != nil { + return xerrors.Errorf("unable to read file containing service account token: %w", err) + } + + params := map[string]interface{}{ + "jwt": string(jwt), + "role": "mongodbenterprise", // the name of the role in Vault that was created with this app's Kubernetes service account bound to it + } + + // log in to Vault's Kubernetes auth method + resp, err := v.client.Logical().Write("auth/kubernetes/login", params) + if err != nil { + return xerrors.Errorf("unable to log in with Kubernetes auth: %w", err) + } + + if resp == nil || resp.Auth == nil || resp.Auth.ClientToken == "" { + return xerrors.Errorf("login response did not return client token") + } + + // will use the resulting Vault token for making all future calls to Vault + v.client.SetToken(resp.Auth.ClientToken) + return nil +} + +func (v *VaultClient) PutSecret(path string, data map[string]interface{}) error { + if err := v.Login(); err != nil { + return xerrors.Errorf("unable to log in: %w", err) + } + _, err := v.client.Logical().Write(path, data) + if err != nil { + return err + } + return nil +} + +func (v *VaultClient) ReadSecretVersion(path string) (int, error) { + data, err := v.ReadSecret(path) + if err != nil { + return -1, err + } + current_version, err := data.Data["current_version"].(json.Number).Int64() + if err != nil { + // this shouldn't happen but if it does the caller should log it with error that + // secret rotation won't work + return -1, err + } + return int(current_version), nil +} + +func (v *VaultClient) ReadSecret(path string) (*api.Secret, error) { + if err := v.Login(); err != nil { + return nil, xerrors.Errorf("unable to log in: %w", err) + } + secret, err := v.client.Logical().Read(path) + if err != nil { + return nil, xerrors.Errorf("can't read secret from vault: %w", err) + } + if secret == nil { + return nil, xerrors.Errorf("secret not found at %s", path) + } + return secret, nil +} + +func (v *VaultClient) ReadSecretBytes(path string) (map[string][]byte, error) { + secret, err := v.ReadSecret(path) + if err != nil { + return map[string][]byte{}, err + } + + secrets := make(map[string][]byte) + for k, v := range maputil.ReadMapValueAsMap(secret.Data, "data") { + secrets[k] = []byte(fmt.Sprintf("%v", v)) + } + return secrets, nil +} + +func (v *VaultClient) ReadSecretString(path string) (map[string]string, error) { + secretBytes, err := v.ReadSecretBytes(path) + if err != nil { + return map[string]string{}, err + } + + secretString := map[string]string{} + for k, v := range secretBytes { + secretString[k] = string(v) + } + return secretString, nil +} + +func (v VaultConfiguration) TLSAnnotations() map[string]string { + if v.TLSSecretRef == "" { + return map[string]string{} + } + return map[string]string{ + "vault.hashicorp.com/tls-secret": v.TLSSecretRef, + "vault.hashicorp.com/ca-cert": "/vault/tls/ca.crt", + } +} + +func (v *VaultClient) OperatorSecretPath() string { + if v.VaultConfig.OperatorSecretPath != "" { + return fmt.Sprintf("/secret/data/%s", v.VaultConfig.OperatorSecretPath) + } + return fmt.Sprintf("/secret/data/%s", DEFAULT_OPERATOR_SECRET_PATH) +} + +func (v *VaultClient) OperatorScretMetadataPath() string { + if v.VaultConfig.OperatorSecretPath != "" { + return fmt.Sprintf("/secret/metadata/%s", v.VaultConfig.OperatorSecretPath) + } + return fmt.Sprintf("/secret/metadata/%s", DEFAULT_OPERATOR_SECRET_PATH) +} + +func (v *VaultClient) OpsManagerSecretPath() string { + if v.VaultConfig.OperatorSecretPath != "" { + return fmt.Sprintf("/secret/data/%s", v.VaultConfig.OpsManagerSecretPath) + } + return fmt.Sprintf("/secret/data/%s", DEFAULT_OPS_MANAGER_SECRET_PATH) +} + +func (v *VaultClient) OpsManagerSecretMetadataPath() string { + if v.VaultConfig.OpsManagerSecretPath != "" { + return fmt.Sprintf("/secret/metadata/%s", v.VaultConfig.OpsManagerSecretPath) + } + return fmt.Sprintf("/secret/metadata/%s", DEFAULT_OPS_MANAGER_SECRET_PATH) +} + +func (v *VaultClient) DatabaseSecretPath() string { + if v.VaultConfig.OperatorSecretPath != "" { + return fmt.Sprintf("/secret/data/%s", v.VaultConfig.DatabaseSecretPath) + } + return fmt.Sprintf("/secret/data/%s", DEFAULT_DATABASE_SECRET_PATH) +} + +func (v *VaultClient) DatabaseSecretMetadataPath() string { + if v.VaultConfig.OperatorSecretPath != "" { + return fmt.Sprintf("/secret/metadata/%s", v.VaultConfig.DatabaseSecretPath) + } + return fmt.Sprintf("/secret/metadata/%s", DEFAULT_DATABASE_SECRET_PATH) +} + +func (v *VaultClient) AppDBSecretPath() string { + if v.VaultConfig.AppDBSecretPath != "" { + return fmt.Sprintf("/secret/data/%s", v.VaultConfig.AppDBSecretPath) + } + return fmt.Sprintf("/secret/data/%s", APPDB_SECRET_BASE_PATH) +} + +func (v *VaultClient) AppDBSecretMetadataPath() string { + if v.VaultConfig.AppDBSecretPath != "" { + return fmt.Sprintf("/secret/metadata/%s", v.VaultConfig.AppDBSecretPath) + } + return fmt.Sprintf("/secret/metadata/%s", APPDB_SECRET_BASE_PATH) +} + +func (s OpsManagerSecretsToInject) OpsManagerAnnotations(namespace string) map[string]string { + var opsManagerSecretPath string + if s.Config.OpsManagerSecretPath != "" { + opsManagerSecretPath = fmt.Sprintf("/secret/data/%s", s.Config.OpsManagerSecretPath) + } else { + opsManagerSecretPath = fmt.Sprintf("/secret/metadata/%s", DEFAULT_OPS_MANAGER_SECRET_PATH) + } + + annotations := map[string]string{ + "vault.hashicorp.com/agent-inject": "true", + "vault.hashicorp.com/role": OpsManagerVaultRoleName, + "vault.hashicorp.com/preserve-secret-case": "true", + } + + annotations = merge.StringToStringMap(annotations, s.Config.TLSAnnotations()) + + if s.TLSSecretName != "" { + omTLSPath := fmt.Sprintf("%s/%s/%s", opsManagerSecretPath, namespace, s.TLSSecretName) + annotations["vault.hashicorp.com/agent-inject-secret-om-tls-cert-pem"] = omTLSPath + annotations["vault.hashicorp.com/agent-inject-file-om-tls-cert-pem"] = s.TLSHash + annotations["vault.hashicorp.com/secret-volume-path-om-tls-cert-pem"] = util.MmsPemKeyFileDirInContainer + annotations["vault.hashicorp.com/agent-inject-template-om-tls-cert-pem"] = fmt.Sprintf(`{{- with secret "%s" -}} + {{ range $k, $v := .Data.data }} + {{- $v }} + {{- end }} + {{- end }}`, omTLSPath) + } + + if s.GenKeyPath != "" { + genKeyPath := fmt.Sprintf("%s/%s/%s", opsManagerSecretPath, namespace, s.GenKeyPath) + annotations["vault.hashicorp.com/agent-inject-secret-gen-key"] = genKeyPath + annotations["vault.hashicorp.com/agent-inject-file-gen-key"] = "gen.key" + annotations["vault.hashicorp.com/secret-volume-path-gen-key"] = util.GenKeyPath + annotations["vault.hashicorp.com/agent-inject-template-gen-key"] = fmt.Sprintf(`{{- with secret "%s" -}} + {{ range $k, $v := .Data.data }} + {{- base64Decode $v }} + {{- end }} + {{- end }}`, genKeyPath) + } + + // add appDB connection string + appDBConnPath := fmt.Sprintf("%s/%s/%s", opsManagerSecretPath, namespace, s.AppDBConnection) + annotations["vault.hashicorp.com/agent-inject-secret-appdb-connection-string"] = appDBConnPath + annotations["vault.hashicorp.com/agent-inject-file-appdb-connection-string"] = "connectionString" + annotations["vault.hashicorp.com/secret-volume-path-appdb-connection-string"] = s.AppDBConnectionVolume + annotations["vault.hashicorp.com/agent-inject-template-appdb-connection-string"] = fmt.Sprintf(`{{- with secret "%s" -}} + {{ .Data.data.connectionString }} + {{- end }}`, appDBConnPath) + return annotations +} + +func (s DatabaseSecretsToInject) DatabaseAnnotations(namespace string) map[string]string { + var databaseSecretPath string + if s.Config.DatabaseSecretPath != "" { + databaseSecretPath = fmt.Sprintf("/secret/data/%s", s.Config.DatabaseSecretPath) + } else { + databaseSecretPath = fmt.Sprintf("/secret/data/%s", DEFAULT_DATABASE_SECRET_PATH) + } + + apiKeySecretPath := fmt.Sprintf("%s/%s/%s", databaseSecretPath, namespace, s.AgentApiKey) + + agentAPIKeyTemplate := fmt.Sprintf(`{{- with secret "%s" -}} + {{ .Data.data.agentApiKey }} + {{- end }}`, apiKeySecretPath) + + annotations := map[string]string{ + "vault.hashicorp.com/agent-inject": "true", + "vault.hashicorp.com/agent-inject-secret-agentApiKey": apiKeySecretPath, + "vault.hashicorp.com/role": DatabaseVaultRoleName, + "vault.hashicorp.com/secret-volume-path-agentApiKey": "/mongodb-automation/agent-api-key", + "vault.hashicorp.com/preserve-secret-case": "true", + "vault.hashicorp.com/agent-inject-template-agentApiKey": agentAPIKeyTemplate, + } + + annotations = merge.StringToStringMap(annotations, s.Config.TLSAnnotations()) + + if s.AgentCerts != "" { + agentCertsPath := fmt.Sprintf("%s/%s/%s", databaseSecretPath, namespace, s.AgentCerts) + annotations["vault.hashicorp.com/agent-inject-secret-mms-automation-agent-pem"] = agentCertsPath + annotations["vault.hashicorp.com/secret-volume-path-mms-automation-agent-pem"] = "/mongodb-automation/agent-certs" + annotations["vault.hashicorp.com/agent-inject-template-mms-automation-agent-pem"] = fmt.Sprintf(`{{- with secret "%s" -}} + {{ range $k, $v := .Data.data }} + {{- $v }} + {{- end }} + {{- end }}`, agentCertsPath) + } + if s.InternalClusterAuth != "" { + internalClusterPath := fmt.Sprintf("%s/%s/%s", databaseSecretPath, namespace, s.InternalClusterAuth) + + annotations["vault.hashicorp.com/agent-inject-secret-internal-cluster"] = internalClusterPath + annotations["vault.hashicorp.com/agent-inject-file-internal-cluster"] = s.InternalClusterHash + annotations["vault.hashicorp.com/secret-volume-path-internal-cluster"] = util.InternalClusterAuthMountPath + annotations["vault.hashicorp.com/agent-inject-template-internal-cluster"] = fmt.Sprintf(`{{- with secret "%s" -}} + {{ range $k, $v := .Data.data }} + {{- $v }} + {{- end }} + {{- end }}`, internalClusterPath) + } + if s.MemberClusterAuth != "" { + memberClusterPath := fmt.Sprintf("%s/%s/%s", databaseSecretPath, namespace, s.InternalClusterAuth) + + annotations["vault.hashicorp.com/agent-inject-secret-tls-certificate"] = memberClusterPath + annotations["vault.hashicorp.com/agent-inject-file-tls-certificate"] = s.MemberClusterHash + annotations["vault.hashicorp.com/secret-volume-path-tls-certificate"] = util.TLSCertMountPath + annotations["vault.hashicorp.com/agent-inject-template-tls-certificate"] = fmt.Sprintf(`{{- with secret "%s" -}} + {{ range $k, $v := .Data.data }} + {{- $v }} + {{- end }} + {{- end }}`, memberClusterPath) + } + + if s.Prometheus != "" { + promPath := fmt.Sprintf("%s/%s/%s", databaseSecretPath, namespace, s.Prometheus) + + annotations["vault.hashicorp.com/agent-inject-secret-prom-https-cert"] = promPath + annotations["vault.hashicorp.com/agent-inject-file-prom-https-cert"] = s.PrometheusTLSCertHash + annotations["vault.hashicorp.com/secret-volume-path-prom-https-cert"] = util.SecretVolumeMountPathPrometheus + annotations["vault.hashicorp.com/agent-inject-template-prom-https-cert"] = fmt.Sprintf(`{{- with secret "%s" -}} + {{ range $k, $v := .Data.data }} + {{- $v }} + {{- end }} + {{- end }}`, promPath) + } + + return annotations +} + +func (v *VaultClient) GetSecretAnnotation(path string) map[string]string { + n, err := v.ReadSecretVersion(path) + if err != nil { + return map[string]string{} + } + + ss := strings.Split(path, "/") + secretName := ss[len(ss)-1] + + return map[string]string{ + secretName: strconv.FormatInt(int64(n), 10), + } +} + +func (a AppDBSecretsToInject) AppDBAnnotations(namespace string) map[string]string { + + annotations := map[string]string{ + "vault.hashicorp.com/agent-inject": "true", + "vault.hashicorp.com/role": AppDBVaultRoleName, + "vault.hashicorp.com/preserve-secret-case": "true", + } + + annotations = merge.StringToStringMap(annotations, a.Config.TLSAnnotations()) + var appdbSecretPath string + if a.Config.AppDBSecretPath != "" { + appdbSecretPath = fmt.Sprintf("/secret/data/%s", a.Config.AppDBSecretPath) + } else { + appdbSecretPath = fmt.Sprintf("/secret/data/%s", APPDB_SECRET_BASE_PATH) + } + if a.AgentApiKey != "" { + + apiKeySecretPath := fmt.Sprintf("%s/%s/%s", appdbSecretPath, namespace, a.AgentApiKey) + agentAPIKeyTemplate := fmt.Sprintf(`{{- with secret "%s" -}} + {{ .Data.data.agentApiKey }} + {{- end }}`, apiKeySecretPath) + + annotations["vault.hashicorp.com/agent-inject-secret-agentApiKey"] = apiKeySecretPath + annotations["vault.hashicorp.com/secret-volume-path-agentApiKey"] = "/mongodb-automation/agent-api-key" + annotations["vault.hashicorp.com/agent-inject-template-agentApiKey"] = agentAPIKeyTemplate + } + + if a.TLSSecretName != "" { + memberClusterPath := fmt.Sprintf("%s/%s/%s", appdbSecretPath, namespace, a.TLSSecretName) + annotations["vault.hashicorp.com/agent-inject-secret-tls-certificate"] = memberClusterPath + annotations["vault.hashicorp.com/agent-inject-file-tls-certificate"] = a.TLSClusterHash + annotations["vault.hashicorp.com/secret-volume-path-tls-certificate"] = util.SecretVolumeMountPath + "/certs" + annotations["vault.hashicorp.com/agent-inject-template-tls-certificate"] = fmt.Sprintf(`{{- with secret "%s" -}} + {{ range $k, $v := .Data.data }} + {{- $v }} + {{- end }} + {{- end }}`, memberClusterPath) + + } + + if a.AutomationConfigSecretName != "" { + // There are two different type of annotations here: for the automation agent + // and for the monitoring agent. + acSecretPath := fmt.Sprintf("%s/%s/%s", appdbSecretPath, namespace, a.AutomationConfigSecretName) + annotations["vault.hashicorp.com/agent-inject-secret-"+a.AgentType] = acSecretPath + annotations["vault.hashicorp.com/agent-inject-file-"+a.AgentType] = a.AutomationConfigPath + annotations["vault.hashicorp.com/secret-volume-path-"+a.AgentType] = "/var/lib/automation/config" + annotations["vault.hashicorp.com/agent-inject-template-"+a.AgentType] = fmt.Sprintf(`{{- with secret "%s" -}} + {{ range $k, $v := .Data.data }} + {{- $v }} + {{- end }} + {{- end }}`, acSecretPath) + } + + if a.PrometheusTLSCertHash != "" && a.PrometheusTLSPath != "" { + promPath := fmt.Sprintf("%s/%s/%s", appdbSecretPath, namespace, a.PrometheusTLSPath) + annotations["vault.hashicorp.com/agent-inject-secret-prom-https-cert"] = promPath + annotations["vault.hashicorp.com/agent-inject-file-prom-https-cert"] = a.PrometheusTLSCertHash + annotations["vault.hashicorp.com/secret-volume-path-prom-https-cert"] = util.SecretVolumeMountPathPrometheus + annotations["vault.hashicorp.com/agent-inject-template-prom-https-cert"] = fmt.Sprintf(`{{- with secret "%s" -}} + {{ range $k, $v := .Data.data }} + {{- $v }} + {{- end }} + {{- end }}`, promPath) + } + + return annotations +} diff --git a/pkg/vault/vaultwatcher/vaultsecretwatch.go b/pkg/vault/vaultwatcher/vaultsecretwatch.go new file mode 100644 index 000000000..77f3ce80c --- /dev/null +++ b/pkg/vault/vaultwatcher/vaultsecretwatch.go @@ -0,0 +1,113 @@ +package vaultwatcher + +import ( + "context" + "fmt" + "strconv" + "time" + + mdbv1 "github.com/10gen/ops-manager-kubernetes/api/v1/mdb" + omv1 "github.com/10gen/ops-manager-kubernetes/api/v1/om" + "github.com/10gen/ops-manager-kubernetes/pkg/vault" + kubernetesClient "github.com/mongodb/mongodb-kubernetes-operator/pkg/kube/client" + "go.uber.org/zap" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" +) + +func WatchSecretChangeForMDB(log *zap.SugaredLogger, watchChannel chan event.GenericEvent, + k8sClient kubernetesClient.Client, vaultClient *vault.VaultClient, resourceType mdbv1.ResourceType) { + + for { + mdbList := &mdbv1.MongoDBList{} + err := k8sClient.List(context.TODO(), mdbList, &client.ListOptions{Namespace: ""}) + if err != nil { + log.Errorf("failed to fetch MongoDBList from Kubernetes: %s", err) + } + + for n, mdb := range mdbList.Items { + // check if we care about the resource type, if not return early + if mdb.Spec.ResourceType != resourceType { + continue + } + // the credentials secret is mandatory and stored in a different path + path := fmt.Sprintf("%s/%s/%s", vaultClient.OperatorScretMetadataPath(), mdb.Namespace, mdb.Spec.Credentials) + latestResourceVersion, currentResourceVersion := getCurrentAndLatestVersion(vaultClient, path, mdb.Spec.Credentials, mdb.Annotations, log) + if latestResourceVersion > currentResourceVersion { + watchChannel <- event.GenericEvent{Object: &mdbList.Items[n]} + break + } + + for _, secretName := range mdb.GetSecretsMountedIntoDBPod() { + path := fmt.Sprintf("%s/%s/%s", vaultClient.DatabaseSecretMetadataPath(), mdb.Namespace, secretName) + latestResourceVersion, currentResourceVersion := getCurrentAndLatestVersion(vaultClient, path, secretName, mdb.Annotations, log) + + if latestResourceVersion > currentResourceVersion { + watchChannel <- event.GenericEvent{Object: &mdbList.Items[n]} + break + } + } + } + + time.Sleep(10 * time.Second) + } +} + +func WatchSecretChangeForOM(log *zap.SugaredLogger, watchChannel chan event.GenericEvent, k8sClient kubernetesClient.Client, vaultClient *vault.VaultClient) { + + for { + omList := &omv1.MongoDBOpsManagerList{} + err := k8sClient.List(context.TODO(), omList, &client.ListOptions{Namespace: ""}) + if err != nil { + log.Errorf("failed to fetch MongoDBOpsManagerList from Kubernetes: %s", err) + } + + triggeredReconciliation := false + for n, om := range omList.Items { + for _, secretName := range om.GetSecretsMountedIntoPod() { + path := fmt.Sprintf("%s/%s/%s", vaultClient.OpsManagerSecretMetadataPath(), om.Namespace, secretName) + latestResourceVersion, currentResourceVersion := getCurrentAndLatestVersion(vaultClient, path, secretName, om.Annotations, log) + + if latestResourceVersion > currentResourceVersion { + watchChannel <- event.GenericEvent{Object: &omList.Items[n]} + triggeredReconciliation = true + break + } + } + if triggeredReconciliation { + break + } + for _, secretName := range om.Spec.AppDB.GetSecretsMountedIntoPod() { + path := fmt.Sprintf("%s/%s/%s", vaultClient.AppDBSecretMetadataPath(), om.Namespace, secretName) + latestResourceVersion, currentResourceVersion := getCurrentAndLatestVersion(vaultClient, path, secretName, om.Annotations, log) + + if latestResourceVersion > currentResourceVersion { + watchChannel <- event.GenericEvent{Object: &omList.Items[n]} + break + } + } + } + + time.Sleep(10 * time.Second) + } + +} + +func getCurrentAndLatestVersion(vaultClient *vault.VaultClient, path string, annotationKey string, annotations map[string]string, log *zap.SugaredLogger) (int, int) { + latestResourceVersion, err := vaultClient.ReadSecretVersion(path) + if err != nil { + log.Errorf("failed to fetch secret revision for the path %s, err: %v", path, err) + } + + // read the secret version from the annotation + currentResourceAnnotation := annotations[annotationKey] + + var currentResourceVersion int + if currentResourceAnnotation == "" { + currentResourceVersion = latestResourceVersion + } else { + currentResourceVersion, _ = strconv.Atoi(currentResourceAnnotation) + } + + return latestResourceVersion, currentResourceVersion +} diff --git a/pkg/webhook/certificates.go b/pkg/webhook/certificates.go new file mode 100644 index 000000000..6e810b32c --- /dev/null +++ b/pkg/webhook/certificates.go @@ -0,0 +1,108 @@ +package webhook + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net" + "os" + "path" + "time" +) + +// createSelfSignedCert creates a self-signed certificate, valid for the +// specified hosts, and returns the certificate and key. +func createSelfSignedCert(hosts []string) (certBytes, privBytes []byte, err error) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, nil, err + } + + privBytes, err = x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return nil, nil, err + } + + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + return nil, nil, err + } + + dnsNames := []string{} + ipAddresses := []net.IP{} + for _, h := range hosts { + if ip := net.ParseIP(h); ip != nil { + ipAddresses = append(ipAddresses, ip) + } else { + dnsNames = append(dnsNames, h) + } + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"MongoDB"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(10, 0, 0), // cert expires in 10 years + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + DNSNames: dnsNames, + IPAddresses: ipAddresses, + } + certBytes, err = x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return nil, nil, err + } + + return certBytes, privBytes, nil +} + +// this whole file is largely cribbed from here: +// https://golang.org/src/crypto/tls/generate_cert.go + +func CreateCertFiles(hosts []string, directory string) error { + certBytes, privBytes, err := createSelfSignedCert(hosts) + if err != nil { + return err + } + + if err := os.MkdirAll(directory, 0755); err != nil { + return err + } + + certOut, err := os.Create(path.Join(directory, "tls.crt")) + if err != nil { + return err + } + + if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: certBytes}); err != nil { + return err + } + + if err := certOut.Close(); err != nil { + return err + } + + keyOut, err := os.OpenFile(path.Join(directory, "tls.key"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err + } + + if err := pem.Encode(keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}); err != nil { + return err + } + + if err := keyOut.Close(); err != nil { + return err + } + + return nil +} diff --git a/pkg/webhook/setup.go b/pkg/webhook/setup.go new file mode 100644 index 000000000..d4b06e22d --- /dev/null +++ b/pkg/webhook/setup.go @@ -0,0 +1,201 @@ +package webhook + +import ( + "context" + "io/ioutil" + + "github.com/10gen/ops-manager-kubernetes/pkg/util" + admissionv1 "k8s.io/api/admissionregistration/v1" + corev1 "k8s.io/api/core/v1" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// This label must match the label used for Operator deployment +const controllerLabelName = "app.kubernetes.io/name" + +// createWebhookService creates a Kubernetes service for the webhook. +func createWebhookService(client client.Client, location types.NamespacedName, webhookPort int, multiClusterMode bool) error { + svcSelector := util.OperatorName + if multiClusterMode { + svcSelector = util.MultiClusterOperatorName + } + + svc := corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: location.Name, + Namespace: location.Namespace, + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "operator", + Port: 443, + Protocol: corev1.ProtocolTCP, + TargetPort: intstr.FromInt(webhookPort), + }, + }, + Selector: map[string]string{ + controllerLabelName: svcSelector, + }, + }, + } + + // create the service if it doesn't already exist + existingService := &corev1.Service{} + err := client.Get(context.TODO(), location, existingService) + if apiErrors.IsNotFound(err) { + return client.Create(context.Background(), &svc) + } else if err != nil { + return err + } + + // Update existing client with resource version and cluster IP + svc.ResourceVersion = existingService.ResourceVersion + svc.Spec.ClusterIP = existingService.Spec.ClusterIP + return client.Update(context.Background(), &svc) +} + +// GetWebhookConfig constructs a Kubernetes configuration resource for the +// validating admission webhook based on the name and namespace of the webhook +// service. +func GetWebhookConfig(serviceLocation types.NamespacedName) admissionv1.ValidatingWebhookConfiguration { + caBytes, err := ioutil.ReadFile("/tmp/k8s-webhook-server/serving-certs/tls.crt") + if err != nil { + panic("could not read CA") + } + + // need to make variables as one can't take the address of a constant + var scope = admissionv1.NamespacedScope + var sideEffects = admissionv1.SideEffectClassNone + var failurePolicy = admissionv1.Ignore + var port int32 = 443 + dbPath := "/validate-mongodb-com-v1-mongodb" + dbmultiPath := "/validate-mongodb-com-v1-mongodbmulticluster" + omPath := "/validate-mongodb-com-v1-mongodbopsmanager" + return admissionv1.ValidatingWebhookConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mdbpolicy.mongodb.com", + }, + Webhooks: []admissionv1.ValidatingWebhook{ + { + Name: "mdbpolicy.mongodb.com", + ClientConfig: admissionv1.WebhookClientConfig{ + Service: &admissionv1.ServiceReference{ + Name: serviceLocation.Name, + Namespace: serviceLocation.Namespace, + Path: &dbPath, + // NOTE: port isn't supported in k8s 1.11 and lower. It works in + // 1.15 but I am unsure about the intervening versions. + Port: &port, + }, + CABundle: caBytes, + }, + Rules: []admissionv1.RuleWithOperations{ + { + Operations: []admissionv1.OperationType{ + admissionv1.Create, + admissionv1.Update, + }, + Rule: admissionv1.Rule{ + APIGroups: []string{"mongodb.com"}, + APIVersions: []string{"*"}, + Resources: []string{"mongodb"}, + Scope: &scope, + }, + }, + }, + AdmissionReviewVersions: []string{"v1"}, + SideEffects: &sideEffects, + FailurePolicy: &failurePolicy, + }, + { + Name: "mdbmultipolicy.mongodb.com", + ClientConfig: admissionv1.WebhookClientConfig{ + Service: &admissionv1.ServiceReference{ + Name: serviceLocation.Name, + Namespace: serviceLocation.Namespace, + Path: &dbmultiPath, + Port: &port, + }, + CABundle: caBytes, + }, + Rules: []admissionv1.RuleWithOperations{ + { + Operations: []admissionv1.OperationType{ + admissionv1.Create, + admissionv1.Update, + }, + Rule: admissionv1.Rule{ + APIGroups: []string{"mongodb.com"}, + APIVersions: []string{"*"}, + Resources: []string{"mongodbmulticluster"}, + Scope: &scope, + }, + }, + }, + AdmissionReviewVersions: []string{"v1"}, + SideEffects: &sideEffects, + FailurePolicy: &failurePolicy, + }, + { + Name: "ompolicy.mongodb.com", + ClientConfig: admissionv1.WebhookClientConfig{ + Service: &admissionv1.ServiceReference{ + Name: serviceLocation.Name, + Namespace: serviceLocation.Namespace, + Path: &omPath, + // NOTE: port isn't supported in k8s 1.11 and lower. It works in + // 1.15 but I am unsure about the intervening versions. + Port: &port, + }, + CABundle: caBytes, + }, + Rules: []admissionv1.RuleWithOperations{ + { + Operations: []admissionv1.OperationType{ + admissionv1.Create, + admissionv1.Update, + }, + Rule: admissionv1.Rule{ + APIGroups: []string{"mongodb.com"}, + APIVersions: []string{"*"}, + Resources: []string{"opsmanagers"}, + Scope: &scope, + }, + }, + }, + AdmissionReviewVersions: []string{"v1"}, + SideEffects: &sideEffects, + FailurePolicy: &failurePolicy, + }, + }, + } +} + +func Setup(client client.Client, serviceLocation types.NamespacedName, certDirectory string, webhookPort int, multiClusterMode bool) error { + if err := createWebhookService(client, serviceLocation, webhookPort, multiClusterMode); err != nil { + return err + } + + certHosts := []string{serviceLocation.Name + "." + serviceLocation.Namespace + ".svc"} + if err := CreateCertFiles(certHosts, certDirectory); err != nil { + return err + } + + webhookConfig := GetWebhookConfig(serviceLocation) + err := client.Create(context.Background(), &webhookConfig) + if apiErrors.IsAlreadyExists(err) { + // client.Update results in internal K8s error "Invalid value: 0x0: must be specified for an update" + // (see https://github.com/kubernetes/kubernetes/issues/80515) + // this fixed in K8s 1.16.0+ + if err := client.Delete(context.Background(), &webhookConfig); err != nil { + return err + } + return client.Create(context.Background(), &webhookConfig) + } + return err +} diff --git a/production_notes/README.md b/production_notes/README.md new file mode 100644 index 000000000..95814217e --- /dev/null +++ b/production_notes/README.md @@ -0,0 +1,57 @@ +## Running Load Tests + +### Deploy Monitoring + +The monitoring setup will install Prometheus and Grafana. Promethues is configured with persistant storage and retention. Feel free to configure them in the promethues configmap (`04-cm.yaml`) to suit your needs. + +* `kubectl apply -f monitoring/`. + +* Add the prometheus svc(http://prometheus-int:8080) as the [source](https://grafana.com/docs/grafana/latest/datasources/add-a-data-source/) for Grafana. +* Create dasboards in Grafana following this [instruction](https://grafana.com/docs/grafana/latest/getting-started/getting-started/#step-3-create-a-dashboard). + +* Following are some of the queries you can add to get the necessary metrics from the operator: + * __CPU Usage(millicores)__: + ```bash + sum(rate(container_cpu_usage_seconds_total{namespace="mongodb", pod=~"om-operator-.*"}[2m])) by (pod) * 1000 + ``` + * __Memory Usage(MB)__: + ```bash + sum(container_memory_usage_bytes{namespace="mongodb",pod=~"om-operator-.*", container="mongodb-enterprise-operator"}) by (pod_name) / 1e6 + ``` + * __Reconcile Time(P90 seconds)__: + ```bash + histogram_quantile(0.90, sum by (controller, le) (rate(controller_runtime_reconcile_time_seconds_bucket{controller="mongodbreplicaset-controller"}[1m]))) * 1e3 + ``` + * __Reconcile Time(average seconds)__: + ```bash + (rate(controller_runtime_reconcile_time_seconds_sum{controller="mongodbreplicaset-controller"}[1m]) / rate(controller_runtime_reconcile_time_seconds_count{controller="mongodbreplicaset-controller"}[1m])) * 1e3 + ``` + * __File Descriptor Count__: + ```bash + container_file_descriptors{namespace="mongodb",pod=~"om-operator-.*",container=~"mongodb-.*"} + ``` + _Note: You might need to change the pod_name in the queries based on your configuration_ +### Deploy Operator and OpsManager + +* Create a namespace for running the tests: `kubectl create ns mongodb`. +* Update the helm chart dependency: `helm dep update helm_charts/opsmanager/`. +_Note: Make sure you've sufficient CPU/Memory reources in your cluster to deploy or adjust the resources in the yaml files accordingly_. +* Deploy OpsManager + Operator + Cert-manager: `helm install om helm_charts/opsmanager/`. + _Note: If you have an existing deployment of cert-manager, the above command may error out. In that case, delete the existing `cert-manager` deployment and its corresponding CRDs._ +* Wait for the ops-manager CR to reach `Running` state. + +### Deploy MongoDB Replicasets - Loadtests + +* Build the `runtest` binary in the path `cmd/runtest`. +* The `runtest` binary is used to deploy MongoDB Replicasets and loadtest the operator setup. +* Run `./runtest --help` to checkout the various settings possible to deploy mongodbs. +* Ex command: +```bash + ./runtest --prometheus-url $prometheus_url --time-to-wait 80m --ops-manager-release-name om --tls true --mongodb-rs-count 1 + ``` + _Note: `$prometheus_url` can be obtained from the `loadbalancer-url` of the `prometheus-ext` service. The `runtest` command needs the prometheus-url to query the prometheus server we deployed to compute the CPU/Mmeory average metrics._ +* Checkout the Grafana dashboard (deployed while installing the monitoring) to get insights about the operator metrics you would like to test. +* Additionally, to run [YCSB](https://github.com/brianfrankcooper/YCSB) against the mongoDB deployments you can execute the command. + ```bash + helm install ycsb --set binding=$mongodb-rs-name-"binding" --set tls=true helm_charts/ycsb/ + ``` diff --git a/production_notes/cmd/runtest/pvc.yaml b/production_notes/cmd/runtest/pvc.yaml new file mode 100644 index 000000000..f8ec201a8 --- /dev/null +++ b/production_notes/cmd/runtest/pvc.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + labels: + app: mongo-0 + controller: mongodb-enterprise-operator + pod-anti-affinity: mongo-0 + name: data-mongo-0-0 + namespace: mongodb +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 16G + storageClassName: kops-ssd-1-17 + volumeMode: Filesystem diff --git a/production_notes/cmd/runtest/runtest.go b/production_notes/cmd/runtest/runtest.go new file mode 100644 index 000000000..422c2753c --- /dev/null +++ b/production_notes/cmd/runtest/runtest.go @@ -0,0 +1,429 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "sync" + "time" + + "github.com/10gen/ops-manager-kubernetes/production_notes/pkg/monitor" + "github.com/10gen/ops-manager-kubernetes/production_notes/pkg/ycsb" + "github.com/prometheus/client_golang/api" + flag "github.com/spf13/pflag" + "golang.org/x/xerrors" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" +) + +var ( + count int + waitTime time.Duration + prometheusURL string + deployOperatorAndOpsManager bool + deployYCSB bool + opsManagerReleaseName string + tlsEnabled bool + cleanupResources bool +) + +func parseFlags() { + flag.IntVar(&count, "mongodb-rs-count", 1, "count of mongodb replicaset to deploy") + flag.DurationVar(&waitTime, "time-to-wait", 2*time.Minute, "time to wait for the test to finish in minutes") + flag.StringVar(&prometheusURL, "prometheus-url", "", "URL of prometheus server to be scrapped") + flag.BoolVar(&deployOperatorAndOpsManager, "deploy-operator-opsmanager", false, "should deploy the operator and ops-manager") + flag.BoolVar(&deployYCSB, "deploy-YCSB", false, "should deploy YCSB") + flag.BoolVar(&tlsEnabled, "tls", false, "enables TLS for MongoDB") + flag.StringVar(&opsManagerReleaseName, "ops-manager-release-name", "", "ops/operator manager release name") + flag.BoolVar(&cleanupResources, "cleanup", false, "cleanup installed resources on successful completion") + flag.Parse() +} + +// deployMongoDB deploys an instance of mongoDB replicaset +func deployMongoDB(ctx context.Context, name string, certsName string, opsManagerCredentials map[string]string) error { + rsName := fmt.Sprintf("name=%s", name) + opsManagerHelmReleaseName := fmt.Sprintf("opsManagerReleaseName=%s", opsManagerReleaseName) + apikey := fmt.Sprintf("opsManager.APIKey=%s", opsManagerCredentials["user"]) + apisecret := fmt.Sprintf("opsManager.APISecret=%s", opsManagerCredentials["publicApiKey"]) + tlsSecretRef := fmt.Sprintf("security.tls.secretRef.name=%s", certsName) + tlsEnabled := fmt.Sprintf("security.tls.enabled=%t", tlsEnabled) + + cmd := exec.Command("helm", "install", "-f", "../../helm_charts/mongodb/values.yaml", "--set", rsName, "--set", opsManagerHelmReleaseName, "--set", apikey, "--set", apisecret, "--set", tlsSecretRef, "--set", tlsEnabled, name, "../../helm_charts/mongodb/replicaSet") + + output, err := cmd.CombinedOutput() + if err != nil { + return xerrors.Errorf("%s", output) + } + + log.Printf("deployed mongoDB replicaset %s: %s", name, string(output)) + return nil +} + +func getSecretStringData(c kubernetes.Clientset, secretName string) (map[string]string, error) { + stringData := map[string]string{} + secret, err := c.CoreV1().Secrets("mongodb").Get(context.TODO(), secretName, metav1.GetOptions{}) + if err != nil { + return stringData, xerrors.Errorf("can't get secret %s: %w", secretName, err) + } + for key, value := range secret.Data { + stringData[key] = string(value) + } + return stringData, nil +} + +// createTLSCerts prepares the TLS certs for the MongoDB Deployment +func createTLSCerts(c kubernetes.Clientset, replicaSetName string, releaseName string) error { + rsName := fmt.Sprintf("name=%s", replicaSetName) + + cmd := exec.Command("helm", "install", "-f", "../../helm_charts/mongodb/values.yaml", "--set", rsName, releaseName, "../../helm_charts/mongodb/certs") + output, err := cmd.CombinedOutput() + if err != nil { + return xerrors.Errorf("%s", output) + } + + log.Printf("Created tls certs for replica set %s under release name %s: %s", replicaSetName, releaseName, string(output)) + + secretNames := make([]string, 3) + for i := 0; i < 3; i++ { + secretNames[i] = fmt.Sprintf("%s-%d-abcd", replicaSetName, i) + } + + err = wait.PollImmediate(time.Second, time.Minute, areSecretsCreated(c, "mongodb", secretNames...)) + if err != nil { + return xerrors.Errorf("secrets weren't created within 1 minute: %w", err) + } + + // Need to read the secrets one by one and create a new one which contains + // the concatenation of the generated key and crt + stringData := map[string]string{} + data := map[string][]byte{} + + for i := 0; i < 3; i++ { + secretStringData, err := getSecretStringData(c, secretNames[i]) + if err != nil { + return xerrors.Errorf("can't read secret data: %w", err) + } + stringData[fmt.Sprintf("%s-%d-pem", replicaSetName, i)] = secretStringData["tls.key"] + secretStringData["tls.crt"] + } + + for s, s2 := range stringData { + data[s] = []byte(s2) + } + _, err = c.CoreV1().Secrets("mongodb").Create(context.TODO(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: releaseName, + Namespace: "mongodb", + Labels: map[string]string{"app.kubernetes.io/managed-by": "runtest"}, + }, + Data: data}, metav1.CreateOptions{}) + if err != nil { + return xerrors.Errorf("can't create secret: %w", err) + } + return nil +} + +func isOperatorReady(c kubernetes.Clientset, deploymentName, namespace string) wait.ConditionFunc { + return func() (bool, error) { + log.Printf("waiting for operator deployment %s to be in ready state...", deploymentName) + dep, err := c.AppsV1().Deployments(namespace).Get(context.TODO(), deploymentName, metav1.GetOptions{}) + if err != nil { + return false, err + } + return *dep.Spec.Replicas == dep.Status.ReadyReplicas, nil + } +} + +func areSecretsCreated(c kubernetes.Clientset, namespace string, names ...string) wait.ConditionFunc { + return func() (bool, error) { + log.Printf("waiting for all secrets %v to be created...", names) + for _, name := range names { + _, err := c.CoreV1().Secrets(namespace).Get(context.TODO(), name, metav1.GetOptions{}) + if err != nil { + return false, nil + } + } + return true, nil + } +} + +// deployOperator deploys mongodb operator instance +func deployMongoDBOperatorAndOpsManager(c kubernetes.Clientset, ctx context.Context) error { + cmd := exec.Command("helm", "install", "om", "../../helm_charts/opsmanager/") + output, err := cmd.CombinedOutput() + if err != nil { + return xerrors.Errorf("%s", output) + } + log.Printf("deploying operator and ops-manager: %s", string(output)) + + err = wait.PollImmediate(time.Second, 2*time.Minute, isOperatorReady(c, "om-operator", "mongodb")) + if err != nil { + return xerrors.Errorf("operator deployment couldn't reach ready state in 2 minutes: %w", err) + } + log.Printf("deployed operator successfully...") + return nil +} + +func hasYCSBJobCompleted(c kubernetes.Clientset, jobName, namespace string) wait.ConditionFunc { + return func() (bool, error) { + log.Printf("waiting for ycsb job %s to complete...", jobName) + job, err := c.BatchV1().Jobs(namespace).Get(context.TODO(), jobName, metav1.GetOptions{}) + if err != nil { + return false, err + } + return job.Status.Succeeded == 1, nil + } +} + +// DeployYCSB deploys ycsb as a job to loadtest mongoDB +func deployYCSBJob(ctx context.Context, c kubernetes.Clientset, mongoDBName string) error { + binding := fmt.Sprintf("binding=%s-binding", mongoDBName) + tlsEnabled := fmt.Sprintf("tls=%t", tlsEnabled) + + cmd := exec.Command("helm", "install", "ycsb", "--set", binding, "--set", tlsEnabled, "../../helm_charts/ycsb/") + + output, err := cmd.CombinedOutput() + if err != nil { + return xerrors.Errorf("%s", output) + } + + log.Printf("deploying ycsb: %s", string(output)) + + err = wait.PollImmediate(time.Second, 2*time.Minute, hasYCSBJobCompleted(c, "ycsb-ycsb-job", "mongodb")) + if err != nil { + return xerrors.Errorf("ycsb job not completed successfully") + } + return nil +} + +func createPrometheusClient(m *monitor.Monitor) error { + pClient, err := api.NewClient(api.Config{ + Address: prometheusURL, + }) + if err != nil { + return err + } + + m.PromClient = pClient + return nil +} + +func createKubernetesClient(m *monitor.Monitor) error { + homePath, ok := os.LookupEnv("HOME") + if !ok { + return xerrors.Errorf("$HOME not set") + } + + kubeconfig := filepath.Join( + homePath, ".kube", "config", + ) + cfg, err := clientcmd.BuildConfigFromFlags("", kubeconfig) + if err != nil { + return err + } + + client, err := kubernetes.NewForConfig(cfg) + if err != nil { + return err + } + m.KubeClient = client + return nil + +} + +func setup() (*monitor.Monitor, error) { + monitor := &monitor.Monitor{} + + if err := createPrometheusClient(monitor); err != nil { + return nil, err + } + + if err := createKubernetesClient(monitor); err != nil { + return nil, err + } + + return monitor, nil +} + +// getTimeDifferenceInSeconds returns the time difference between t2 and t1 with the assumption t2 >= t1 +func getTimeDifferenceInSeconds(t1, t2 time.Time) float64 { + return t2.Sub(t1).Seconds() +} + +// cleanupTLSSecrets ensures that all old secrets from previous runs with TLS enabled are deleted +func cleanupTLSSecrets(c kubernetes.Clientset) error { + // Delete all the certs-x + err := c.CoreV1().Secrets("mongodb").DeleteCollection(context.TODO(), metav1.DeleteOptions{}, metav1.ListOptions{ + LabelSelector: "app.kubernetes.io/managed-by=runtest", + }) + if err != nil { + return err + } + + // Delete all the automatically generated secrets: + return c.CoreV1().Secrets("mongodb").DeleteCollection(context.TODO(), metav1.DeleteOptions{}, metav1.ListOptions{ + FieldSelector: "type=kubernetes.io/tls", + // This is a bit more robust than deleting by name, as we don't have to worry if + // in the future we change the templated name. And we do not have any other + // secret in our testing with this type + }) + +} + +func cleanMongoDBResource(c kubernetes.Clientset, mongoReleaseName string) error { + err := helmUninstall(mongoReleaseName) + if err != nil { + return err + } + return c.CoreV1().PersistentVolumeClaims("mongodb").DeleteCollection(context.TODO(), metav1.DeleteOptions{}, metav1.ListOptions{ + LabelSelector: fmt.Sprintf("app=%s", mongoReleaseName), + }) +} + +func helmUninstall(releaseName string) error { + cmd := exec.Command("helm", "uninstall", releaseName) + output, err := cmd.CombinedOutput() + if err != nil { + return xerrors.Errorf("%s", output) + } + return nil +} + +func cleanup(c kubernetes.Clientset) error { + log.Printf("Cleaning up resources") + for i := 0; i < count; i++ { + if tlsEnabled { + err := helmUninstall(fmt.Sprintf("certs-%d", i)) + if err != nil { + return err + } + } + cleanMongoDBResource(c, fmt.Sprintf("mongo-%d", i)) + } + + if tlsEnabled { + err := cleanupTLSSecrets(c) + if err != nil { + return err + } + } + + if deployYCSB && count == 1 { + return helmUninstall("ycsb") + } + + return nil +} + +func main() { + log.SetFlags(log.LstdFlags | log.Lshortfile) + parseFlags() + + monitor, err := setup() + if err != nil { + log.Fatalf(err.Error()) + } + monitor.Timeout = waitTime + + if cleanupResources { + err := cleanup(*monitor.KubeClient) + if err != nil { + log.Fatalf("cleaning up resources: %s", err) + } + return + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if deployOperatorAndOpsManager { + if err := deployMongoDBOperatorAndOpsManager(*monitor.KubeClient, ctx); err != nil { + log.Fatalf(err.Error()) + } + } else { + log.Print("skipping deploying operator and ops-manager") + } + + err = wait.PollImmediate(30*time.Second, 10*time.Minute, areSecretsCreated(*monitor.KubeClient, "mongodb", "om-ops-manager-admin-key")) + if err != nil { + log.Fatal(err) + } + // Read user and password for ops manager + omAdminKey, err := getSecretStringData(*monitor.KubeClient, "om-ops-manager-admin-key") + if err != nil { + log.Fatal(err) + } + + if tlsEnabled { + for i := 0; i < count; i++ { + err = createTLSCerts(*monitor.KubeClient, fmt.Sprintf("mongo-%d", i), fmt.Sprintf("certs-%d", i)) + if err != nil { + // we don't want to proceed if we had errors creating TLS certs + log.Fatalf(err.Error()) + } + } + } + + // record the start time for deploying mongoDB replicasets + monitor.StartTime = time.Now() + + for i := 0; i < count; i++ { + err = deployMongoDB(ctx, fmt.Sprintf("mongo-%d", i), fmt.Sprintf("certs-%d", i), omAdminKey) + if err != nil { + // we don't want to proceed if we had errors deploying any of the mongodb replicasets + log.Fatalf(err.Error()) + } + } + + var wg sync.WaitGroup + waitCh := make(chan struct{}) + + // start monitoring of each MongoDB replicasets in it's own go-routine + for i := 0; i < count; i++ { + wg.Add(1) + go func(i int) { + monitor.MonitorReplicaSets(ctx, fmt.Sprintf("mongo-%d", i)) + wg.Done() + }(i) + } + + go func() { + wg.Wait() + close(waitCh) + }() + + select { + case <-waitCh: + // get required metrics from the operator over the timeframe needed by mongoDB replicasets to get in "ready" state. + // TODO: make this configurable for multiple replicasets + log.Printf("time taken by mongoDB replicaset to reach ready state: %.2f", getTimeDifferenceInSeconds(monitor.StartTime, monitor.EndTime)) + + monitor.MonitorOperatorReconcileTime(ctx) + monitor.MonitorOperatorResourceUsage(ctx) + + // we only want to run YCSB job when we are loadtesting agains one mongoDB replicaset as per spec + if count == 1 && deployYCSB { + // Added some sleep for ycsb to run sucessfully: https://jira.mongodb.org/browse/CLOUDP-76932 + time.Sleep(20 * time.Second) + + err := deployYCSBJob(ctx, *monitor.KubeClient, "mongo-0") + if err != nil { + log.Printf("error deploying/running ycsb: %v", err) + } else { + err = ycsb.ParseAndUploadYCSBPodLogs(ctx, *monitor.KubeClient, "mongodb", "ycsb-ycsb-job") + if err != nil { + log.Printf("error getting results from ycsb pod: %v", err) + } + } + } + + log.Printf("loadtesting completed...") + case <-time.After(waitTime): + log.Printf("timedout waiting for response...") + } +} diff --git a/production_notes/cmd/setup/setup.go b/production_notes/cmd/setup/setup.go new file mode 100644 index 000000000..3a6675358 --- /dev/null +++ b/production_notes/cmd/setup/setup.go @@ -0,0 +1,89 @@ +package main + +import ( + "log" + + "github.com/10gen/ops-manager-kubernetes/production_notes/pkg/provisioner" + flag "github.com/spf13/pflag" + "golang.org/x/xerrors" +) + +const ( + small string = "M30" + medium string = "M80" + large string = "M300" + + // custom is used when provisioning a cluster for custom tests that do not + // require instances size comparable with Atlas + custom string = "custom" +) + +type provisioningOpts struct { + clusterName string + size string + delete bool + wait bool + kubeConfigExportFile string + networking string +} + +func parseArgs() provisioningOpts { + opts := provisioningOpts{} + flag.StringVar(&opts.size, "size", "", "Size of the cluster {M30,M80,M300,custom}") + flag.BoolVar(&opts.delete, "delete", false, "Delete the cluster before running, if it exists") + flag.BoolVar(&opts.wait, "wait", false, "Wait for the cluster to be ready") + flag.StringVar(&opts.networking, "networking", "", "cni used for provisioning cluster") + flag.StringVar(&opts.clusterName, "clustername", "loadtesting.mongokubernetes.com", "name of the cluster to be provisioned") + flag.StringVar(&opts.kubeConfigExportFile, "save-kube-config", "~/.kube/config", "Export kubeconfig file to the specified location") + flag.Parse() + return opts +} + +func clusterSizeToNodeInstanceSize(size string) (string, error) { + switch size { + case small: + return "m5.large", nil + case medium: + return "m5.4xlarge", nil + case large: + return "m5.24xlarge", nil + case custom: + return "t2.2xlarge", nil + default: + return "", xerrors.Errorf("got an invalid cluster size: %s", size) + } +} + +func main() { + opts := parseArgs() + + if opts.delete { + err := provisioner.DeleteIfExists(opts.clusterName) + if err != nil { + log.Fatalf("Can't execute delete command: %s", err) + } + } + + nodeInstanceSize, err := clusterSizeToNodeInstanceSize(opts.size) + if err != nil { + log.Fatalf("Error in processing arguments: %s", err) + } + + err = provisioner.CreateCluster(opts.clusterName, nodeInstanceSize, opts.networking) + if err != nil { + log.Fatalf("Can't create kops cluster: %s", err) + } + + if opts.wait { + err := provisioner.WaitForClusterToBeReady(opts.clusterName) + if err != nil { + log.Fatalf("Error in waiting for the cluster to be ready %s", err) + } + } + + err = provisioner.ExportKubecfg(opts.clusterName, opts.kubeConfigExportFile) + if err != nil { + log.Fatalf("Error in exporting cluster kubecfg %s", err) + } + +} diff --git a/production_notes/deploy/basic.yaml b/production_notes/deploy/basic.yaml new file mode 100644 index 000000000..d87d92bb1 --- /dev/null +++ b/production_notes/deploy/basic.yaml @@ -0,0 +1,38 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: basic +spec: + members: 3 + version: 4.4.0-ent + type: ReplicaSet + + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + podSpec: + podTemplate: + spec: + containers: + - name: mongodb-enterprise-database + resources: + limits: + cpu: "2" + memory: 1G + requests: + cpu: "1" + memory: 500M + + persistence: + single: + storage: 10G + + security: + tls: + enabled: true + authentication: + enabled: true + modes: ["SCRAM"] diff --git a/production_notes/deploy/big.yaml b/production_notes/deploy/big.yaml new file mode 100644 index 000000000..87d405da1 --- /dev/null +++ b/production_notes/deploy/big.yaml @@ -0,0 +1,39 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: m300-equivalent-replica-set +spec: + members: 3 + version: 4.4.0-ent + type: ReplicaSet + + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + podSpec: + podTemplate: + spec: + containers: + - name: mongodb-enterprise-database + resources: + limits: + cpu: "96" + memory: 384G + requests: + cpu: "96" + memory: 384G + + persistence: + single: + storage: 2T + storageClass: fast-ssd + + security: + tls: + enabled: true + authentication: + enabled: true + modes: ["SCRAM"] diff --git a/production_notes/deploy/medium.yaml b/production_notes/deploy/medium.yaml new file mode 100644 index 000000000..53d6c7782 --- /dev/null +++ b/production_notes/deploy/medium.yaml @@ -0,0 +1,39 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: m80-equivalent-replica-set +spec: + members: 3 + version: 4.4.0-ent + type: ReplicaSet + + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + podSpec: + podTemplate: + spec: + containers: + - name: mongodb-enterprise-database + resources: + limits: + cpu: "32" + memory: 131G + requests: + cpu: "32" + memory: 131G + + persistence: + single: + storage: 760G + storageClass: fast-ssd + + security: + tls: + enabled: true + authentication: + enabled: true + modes: ["SCRAM"] diff --git a/production_notes/deploy/small.yaml b/production_notes/deploy/small.yaml new file mode 100644 index 000000000..dcb1d7df2 --- /dev/null +++ b/production_notes/deploy/small.yaml @@ -0,0 +1,39 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: m30-equivalent-replica-set +spec: + members: 3 + version: 4.4.0-ent + type: ReplicaSet + + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + + podSpec: + podTemplate: + spec: + containers: + - name: mongodb-enterprise-database + resources: + limits: + cpu: "2" + memory: 8G + requests: + cpu: "2" + memory: 8G + + persistence: + single: + storage: 40G + storageClass: fast-ssd + + security: + tls: + enabled: true + authentication: + enabled: true + modes: ["SCRAM"] diff --git a/production_notes/deploy/storageClass.yaml b/production_notes/deploy/storageClass.yaml new file mode 100644 index 000000000..490b955d4 --- /dev/null +++ b/production_notes/deploy/storageClass.yaml @@ -0,0 +1,7 @@ +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: fast-ssd +provisioner: kubernetes.io/aws-ebs +parameters: + type: io2 #io2 and io1 have the same prices. io2 provides more durability, while io1 has the additional support for "Amazon EBS Multi-attach" diff --git a/production_notes/docker/Dockerfile b/production_notes/docker/Dockerfile new file mode 100644 index 000000000..d4d417088 --- /dev/null +++ b/production_notes/docker/Dockerfile @@ -0,0 +1,8 @@ +FROM jmimick/ycsb + +USER root + +COPY run.sh ./run.sh +RUN chmod +x ./run.sh + +ENTRYPOINT ["./run.sh"] diff --git a/production_notes/docker/run.sh b/production_notes/docker/run.sh new file mode 100644 index 000000000..c09519d40 --- /dev/null +++ b/production_notes/docker/run.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +set -vex + +keytool -importcert -trustcacerts -file /etc/tls-cert/ca.crt -keystore /mongotrust.ts -storepass foofoo -noprompt + + +# build connection string from secret mounted +# as environment variables +python /connstring-helper-env.py > /uri + +MDB_URL=$(cat /uri) +echo "target db: ${MDB_URL}" + +export YCSB_HOME="/ycsb" + +# make sure all the params are set and go. +if [[ -z ${ACTION} ]]; then + echo "ACTION env not found, default to 'run'" + ACTION=run +fi +if [[ -z ${DB} ]]; then + echo "DB env not found, default to 'mongodb'" + DB=mongodb +fi + + +cd ${YCSB_HOME} +echo "YCSB - ACTION=${ACTION} DB=${DB}" +echo "== workload start" +echo "Starting workload/work" +cat /work/workload +echo "== workload end" + +./bin/ycsb "${ACTION}" "${DB}" -s -P /work/workload -p mongodb.url="${MDB_URL}" -p maxexecutiontime="900" -jvm-args '-Djavax.net.ssl.trustStore=/mongotrust.ts -Djavax.net.ssl.trustStorePassword=foofoo' diff --git a/production_notes/helm_charts/mongodb/certs/.helmignore b/production_notes/helm_charts/mongodb/certs/.helmignore new file mode 100644 index 000000000..0e8a0eb36 --- /dev/null +++ b/production_notes/helm_charts/mongodb/certs/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/production_notes/helm_charts/mongodb/certs/Chart.yaml b/production_notes/helm_charts/mongodb/certs/Chart.yaml new file mode 100644 index 000000000..df8189672 --- /dev/null +++ b/production_notes/helm_charts/mongodb/certs/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: certs +description: Helm chart for tls certs + +version: 0.0.1 diff --git a/production_notes/helm_charts/mongodb/certs/ca_tls.crt b/production_notes/helm_charts/mongodb/certs/ca_tls.crt new file mode 100644 index 000000000..b5fda90e8 --- /dev/null +++ b/production_notes/helm_charts/mongodb/certs/ca_tls.crt @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +MIIFxTCCA62gAwIBAgIJAPOFvJDHk5FFMA0GCSqGSIb3DQEBCwUAMHExCzAJBgNV +BAYTAlVTMREwDwYDVQQIDAhOZXcgWW9yazERMA8GA1UEBwwITmV3IFlvcmsxEDAO +BgNVBAoMB01vbmdvREIxEDAOBgNVBAsMB21vbmdvZGIxGDAWBgNVBAMMD3d3dy5t +b25nb2RiLmNvbTAeFw0yMDAzMTYxNTU0MjFaFw0yMjEyMTExNTU0MjFaMHExCzAJ +BgNVBAYTAlVTMREwDwYDVQQIDAhOZXcgWW9yazERMA8GA1UEBwwITmV3IFlvcmsx +EDAOBgNVBAoMB01vbmdvREIxEDAOBgNVBAsMB21vbmdvZGIxGDAWBgNVBAMMD3d3 +dy5tb25nb2RiLmNvbTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAKiS +2mCFDE50o9k8YjJPsYd9zo991dOv5ipeXah/hazo8jMG1E/OtSFYG8kRXjfKxAX+ +i/2lX75Q0JKcv04EiZdM8E4bNCP+7VTvgKySLgunXGtTeL+lCeyOyiaO6zvoaVCO +mm0p6ydiNW/7On4PMOJCPq2vVt7Z77BXOiun0OwS3vaxmx6P1D3KQg54YHJjVtP0 +8ZlMQSlGdaHSq2eQP3pDcTqDZ1u5slSZ+nWkM2GMdZpl8Wh9kaq3UwiLlaXdqZmw +OohNywFxSP/bOvDHnZagXuanCXR+221i2j9DMURFetzbXZyErabf/iIX7fazIcQR +PiOmrPTLBzCetgRaTcss8ZLZZSVZ3hVS+0mGZCg6UUvtZ4fv6gjNg0orAjw6FBfC ++ITNxwz2m7oZwIXlCJezaNaZSREkudvIDdhVsR40D/Bkm3EQ31LB3dd9Kcmlb3Gh +dz/Eg04NdhSANsrR+YEvBANJPp1se5Bfd2pHB0QlUUqloLWWvQApg8oSI11SUXRR +mCAYZWB7AEu2ZVnKrkZMDEoK6SCAxLd0y2pIWSz8+GGq81TNbqDTkDgU3D+lZ8iH +SV3kOyWryz5ryiEG6gRIQBJy0whkggbaC/+3Sgkar1hoObv+hdjXZSMKVVGtSndc +nP7MMAHja5q5xnbiBRleyzzTgsNELiUr7NtDPxkrAgMBAAGjYDBeMB0GA1UdDgQW +BBTOdeVRG1IeTf05dxmFLmp8n3eATjAfBgNVHSMEGDAWgBTOdeVRG1IeTf05dxmF +Lmp8n3eATjAPBgNVHRMBAf8EBTADAQH/MAsGA1UdDwQEAwIBBjANBgkqhkiG9w0B +AQsFAAOCAgEAZqMFiwF0XfNqEXv/68gF0iohoN9tu9M4wmLcugzJACkQq+Tc1JHw +uN2sXTyhMdrnqnvD+V6CQqhIDHr/LM+4DAE0Idbmx3DYN5LURiiAm7/nMGHJiP/0 +x5KwWx6IvOS3Qwhl8UtSifaFPEdckukXNdtx7iSa7VCk3dc338JXTqgRTGgg2rMw +eJEf6kaz3nMtqbKG85rFFTEJfLGYF0KRvWUMZEbEU3XHnAb036Rry2GTB5vMvRvY +6zTkcFabpa8tNEhhYa6KEvTlqiJced2r3EEFVbph12+4OIrSFq6WS3PmeXUEHyTi +R0pLlYd/Jw/PKn+eeIjrhsWH0YWdh9AxIIh+kHTvzV/jYQ9sCRbOXT+JFOW8eBSb +jW08K/Qij9rAjs1GFnPX5jzzURx5VzWr9iCyopdzfuO/QQkD9wcWYFknCbySO3KE +eVVji8nFFrJP2za7nI6S1Jqd9lnQsYB5EGEY553Roq0izJ3C3Pu16rL4ac83GKdg +1g6bRLiBtmSen4eHTKT6h/DtzHPfPSDT92K73wjQj+GV5SUn/FFgyJjDa6IL3vlG +ABa/GwemYHXRTH2DtEKOlNde9OFiJilVMRFaD037K7z8FsSn0jy2bTpDXmAjHV8U +KbNmFROA94Pfc6djjAfcJofSdLdEccaw54vmYGanllDtHbYkY1LfuIg= +-----END CERTIFICATE----- diff --git a/production_notes/helm_charts/mongodb/certs/ca_tls.key b/production_notes/helm_charts/mongodb/certs/ca_tls.key new file mode 100644 index 000000000..4056da328 --- /dev/null +++ b/production_notes/helm_charts/mongodb/certs/ca_tls.key @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +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 +qaqzihbJqybwI1/mcbD/kQXUVsS51dWW +-----END PRIVATE KEY----- diff --git a/production_notes/helm_charts/mongodb/certs/templates/ca-issuer.yaml b/production_notes/helm_charts/mongodb/certs/templates/ca-issuer.yaml new file mode 100644 index 000000000..316d7ca25 --- /dev/null +++ b/production_notes/helm_charts/mongodb/certs/templates/ca-issuer.yaml @@ -0,0 +1,10 @@ +{{- if not (lookup "cert-manager.io/v1" "Issuer" .Release.Namespace "ca-issuer") }} +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: ca-issuer + namespace: {{ .Release.Namespace }} +spec: + ca: + secretName: ca-key-pair +{{end}} diff --git a/production_notes/helm_charts/mongodb/certs/templates/ca-key-pair.yaml b/production_notes/helm_charts/mongodb/certs/templates/ca-key-pair.yaml new file mode 100644 index 000000000..b05a01506 --- /dev/null +++ b/production_notes/helm_charts/mongodb/certs/templates/ca-key-pair.yaml @@ -0,0 +1,10 @@ +{{- if not (lookup "v1" "Secret" .Release.Namespace "ca-key-pair") }} +apiVersion: v1 +kind: Secret +metadata: + name: ca-key-pair + namespace: {{ .Release.Namespace }} +data: + tls.crt: {{ $.Files.Get "ca_tls.crt" | b64enc}} + tls.key: {{ $.Files.Get "ca_tls.key" | b64enc}} +{{end}} diff --git a/production_notes/helm_charts/mongodb/certs/templates/issuer-ca.yaml b/production_notes/helm_charts/mongodb/certs/templates/issuer-ca.yaml new file mode 100644 index 000000000..6e9c9d9a0 --- /dev/null +++ b/production_notes/helm_charts/mongodb/certs/templates/issuer-ca.yaml @@ -0,0 +1,10 @@ +{{- if not (lookup "v1" "ConfigMap" .Release.Namespace .Values.security.tls.ca ) }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Values.security.tls.ca }} + namespace: {{ .Release.Namespace }} +data: + ca-pem: {{ $.Files.Get "ca_tls.crt" | quote}} + mms-ca.crt: {{ $.Files.Get "ca_tls.crt" | quote}} +{{end}} diff --git a/production_notes/helm_charts/mongodb/certs/templates/pod-certs.yaml b/production_notes/helm_charts/mongodb/certs/templates/pod-certs.yaml new file mode 100644 index 000000000..905b01305 --- /dev/null +++ b/production_notes/helm_charts/mongodb/certs/templates/pod-certs.yaml @@ -0,0 +1,20 @@ +{{range $index, $e := until ( .Values.members | int ) }} +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: {{ $.Values.name }}-{{$index}} + namespace: {{ $.Release.Namespace }} +spec: + dnsNames: + - {{ $.Values.name }}-{{$index}}.{{ $.Values.name }}.{{ $.Release.Namespace }}.svc.{{ $.Values.clusterName }} + - {{ $.Values.name }}-{{$index}} + secretName: {{ $.Values.name }}-{{$index}}-abcd + issuerRef: + name: ca-issuer + duration: "240h" + renewBefore: "120h" + usages: + - "server auth" + - "client auth" +{{end}} diff --git a/production_notes/helm_charts/mongodb/replicaset/.helmignore b/production_notes/helm_charts/mongodb/replicaset/.helmignore new file mode 100644 index 000000000..0e8a0eb36 --- /dev/null +++ b/production_notes/helm_charts/mongodb/replicaset/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/production_notes/helm_charts/mongodb/replicaset/Chart.yaml b/production_notes/helm_charts/mongodb/replicaset/Chart.yaml new file mode 100644 index 000000000..93eadafb9 --- /dev/null +++ b/production_notes/helm_charts/mongodb/replicaset/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v2 +name: mongodb-enterprise-database +description: MDB charts +version: 0.0.1 diff --git a/production_notes/helm_charts/mongodb/replicaset/crds b/production_notes/helm_charts/mongodb/replicaset/crds new file mode 120000 index 000000000..31b1c6174 --- /dev/null +++ b/production_notes/helm_charts/mongodb/replicaset/crds @@ -0,0 +1 @@ +../../../../public/helm_chart/crds \ No newline at end of file diff --git a/production_notes/helm_charts/mongodb/replicaset/templates/binding.yaml b/production_notes/helm_charts/mongodb/replicaset/templates/binding.yaml new file mode 100644 index 000000000..ebf13ac79 --- /dev/null +++ b/production_notes/helm_charts/mongodb/replicaset/templates/binding.yaml @@ -0,0 +1,15 @@ +{{- range .Values.users }} + {{ if eq .username "ycsb" }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ $.Release.Name }}-binding + namespace: {{ $.Release.Namespace }} + labels: + product: {{ $.Chart.Name }} +stringData: + uri: 'mongodb+srv://{{ $.Release.Name }}.{{ $.Release.Namespace }}.svc.{{ $.Values.clusterName }}/test?ssl={{ $.Values.security.tls.enabled }}' + username: {{ .username }} + password: {{ .password }} + {{- end }} +{{- end }} diff --git a/production_notes/helm_charts/mongodb/replicaset/templates/database-cm.yaml b/production_notes/helm_charts/mongodb/replicaset/templates/database-cm.yaml new file mode 100644 index 000000000..fa9ca0920 --- /dev/null +++ b/production_notes/helm_charts/mongodb/replicaset/templates/database-cm.yaml @@ -0,0 +1,19 @@ +{{- if not .Values.opsManager.configMap }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Values.name }}-configmap + namespace: {{ .Release.Namespace }} +data: + projectName: {{ .Values.name }} + baseUrl: http://{{ .Values.opsManagerReleaseName }}-ops-manager-svc.{{ .Release.Namespace }}.svc.cluster.local:8080 + + # Optional parameters + + # If orgId is omitted a new organization will be created, with the same name as the Project. + # Also API Key used must have global admin permissions + {{- if .Values.opsManager.orgid }} + orgId: {{ .Values.opsManager.orgid | quote }} + {{- end }} +{{- end }} diff --git a/production_notes/helm_charts/mongodb/replicaset/templates/database-secret.yaml b/production_notes/helm_charts/mongodb/replicaset/templates/database-secret.yaml new file mode 100644 index 000000000..8e683f57e --- /dev/null +++ b/production_notes/helm_charts/mongodb/replicaset/templates/database-secret.yaml @@ -0,0 +1,12 @@ +{{- if not .Values.opsManager.secretRef }} +--- +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Values.name }}-credential + namespace: {{ .Release.Namespace }} +type: Opaque +data: + user: {{ .Values.opsManager.APIKey | b64enc }} + publicApiKey: {{ .Values.opsManager.APISecret | b64enc }} +{{- end }} diff --git a/production_notes/helm_charts/mongodb/replicaset/templates/database.yaml b/production_notes/helm_charts/mongodb/replicaset/templates/database.yaml new file mode 100644 index 000000000..9917687e4 --- /dev/null +++ b/production_notes/helm_charts/mongodb/replicaset/templates/database.yaml @@ -0,0 +1,43 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: {{ .Release.Name }} + namespace: {{ .Release.Namespace }} + +spec: + type: {{ .Values.type | quote }} + members: {{ .Values.members }} + + service: {{ .Release.Name }} + # Using a version >= 4.0 will enable SCRAM-SHA-256 authentication + # setting a version < 4.0 will enable SCRAM-SHA-1/MONGODB-CR authentication + version: 4.0.4-ent + + opsManager: + configMapRef: +{{- if .Values.opsManager.configMap }} + name: {{ .Values.opsManager.configMap }} +{{- else }} + name: {{ .Values.name }}-configmap +{{- end }} +{{- if .Values.opsManager.secretRef }} + credentials: {{ .Values.opsManager.secretRef }} +{{- else }} + credentials: {{ .Values.name }}-credential +{{- end }} + + security: + authentication: + enabled: true + modes: + {{- range .Values.security.authentication.modes }} + - {{ . | quote }} # Valid authentication modes are "SCRAM' and "X509" + {{- end }} + {{- if .Values.security.tls.enabled }} + tls: + enabled: {{ .Values.security.tls.enabled }} + ca: {{ .Values.security.tls.ca }} + secretRef: + name: {{ .Values.security.tls.secretRef.name }} + {{- end }} diff --git a/production_notes/helm_charts/mongodb/replicaset/templates/mongodb-user-password.yaml b/production_notes/helm_charts/mongodb/replicaset/templates/mongodb-user-password.yaml new file mode 100644 index 000000000..4d2e18d49 --- /dev/null +++ b/production_notes/helm_charts/mongodb/replicaset/templates/mongodb-user-password.yaml @@ -0,0 +1,11 @@ +{{- range .Values.users }} +--- +apiVersion: v1 +kind: Secret +metadata: + name: {{ $.Values.name }}-{{ .username }}-secret + namespace: {{ $.Release.Namespace }} +type: Opaque +stringData: + password: {{ .password | quote}} +{{- end }} \ No newline at end of file diff --git a/production_notes/helm_charts/mongodb/replicaset/templates/mongodb-user.yaml b/production_notes/helm_charts/mongodb/replicaset/templates/mongodb-user.yaml new file mode 100644 index 000000000..4661784a8 --- /dev/null +++ b/production_notes/helm_charts/mongodb/replicaset/templates/mongodb-user.yaml @@ -0,0 +1,18 @@ +{{- range .Values.users }} +--- +apiVersion: mongodb.com/v1 +kind: MongoDBUser +metadata: + name: {{ $.Values.name }}-{{ .username }}-mongodbuser + namespace: {{ $.Release.Namespace }} +spec: + passwordSecretKeyRef: + name: {{ $.Values.name }}-{{ .username }}-secret # the name of the secret that stores this user's password + key: password # the key in the secret that stores the password + username: {{ .username }} + db: {{ .db }} + mongodbResourceRef: + name: {{ $.Values.name }} # The name of the MongoDB resource this user will be added to + roles: + {{- toYaml .roles | nindent 6 }} +{{- end }} diff --git a/production_notes/helm_charts/mongodb/values.yaml b/production_notes/helm_charts/mongodb/values.yaml new file mode 100644 index 000000000..85104950d --- /dev/null +++ b/production_notes/helm_charts/mongodb/values.yaml @@ -0,0 +1,53 @@ +## MongoDB Enterprise Database + +# Set this to true if your cluster is managing SecurityContext for you. +# If running OpenShift (Cloud, Minishift, etc.), set this to true. +managedSecurityContext: false + +# Optional configuration. +deployValidationWebhooks: true + +opsManagerReleaseName: + +name: +type: ReplicaSet +members: 3 +opsManager: + # Ops Manager connection need to be configured with Values and This HELM chart will create + # necessary Secret and Config Map. + APIKey: + APISecret: + +security: + authentication: + modes: ["SCRAM"] # Valid authentication modes are "SCRAM", "LDAP" and "X509" + tls: + enabled: true + ca: issuer-ca + secretRef: + name: certs + +clusterName: cluster.local + +registry: + pullPolicy: Always + +users: + - username: ycsb + db: admin + password: "ycsbpassword" + roles: + - db: admin + name: readWriteAnyDatabase + - username: admin-user + db: admin + password: "%SomeLong%password$foradmin" + roles: + - db: admin + name: clusterAdmin + - db: admin + name: userAdminAnyDatabase + - db: admin + name: readWrite + - db: admin + name: userAdminAnyDatabase diff --git a/production_notes/helm_charts/operator/.helmignore b/production_notes/helm_charts/operator/.helmignore new file mode 100644 index 000000000..0e8a0eb36 --- /dev/null +++ b/production_notes/helm_charts/operator/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/production_notes/helm_charts/operator/Chart.yaml b/production_notes/helm_charts/operator/Chart.yaml new file mode 100644 index 000000000..f7b853a55 --- /dev/null +++ b/production_notes/helm_charts/operator/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: operator +description: MongoDB Enterprise Operator + +version: 0.0.1 diff --git a/production_notes/helm_charts/operator/crds b/production_notes/helm_charts/operator/crds new file mode 120000 index 000000000..39982bb7b --- /dev/null +++ b/production_notes/helm_charts/operator/crds @@ -0,0 +1 @@ +../../../public/helm_chart/crds \ No newline at end of file diff --git a/production_notes/helm_charts/operator/templates/operator-roles.yaml b/production_notes/helm_charts/operator/templates/operator-roles.yaml new file mode 100644 index 000000000..89d6a45c6 --- /dev/null +++ b/production_notes/helm_charts/operator/templates/operator-roles.yaml @@ -0,0 +1,145 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Release.Name }}-operator + namespace: {{ .Release.Namespace }} +{{- if .Values.registry.imagePullSecrets}} +imagePullSecrets: + - name: {{ .Values.registry.imagePullSecrets }} +{{- end }} + + +--- +kind: {{ if eq (.Values.watchNamespace | default "") "*" }} ClusterRole {{ else }} Role {{ end }} +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: {{ .Values.name }} + {{- if not (eq (.Values.watchNamespace | default "*") "*") }} + namespace: {{ .Values.watchNamespace }} + {{- else }} + namespace: {{ .Release.Namespace }} + {{- end }} + +rules: +- apiGroups: + - "" + resources: + - configmaps + - secrets + - services + - pods + verbs: + - get + - list + - create + - update + - delete + - watch +- apiGroups: + - apps + resources: + - statefulsets + verbs: + - create + - get + - list + - watch + - delete + - update + {{- if eq (.Values.watchNamespace | default "") "*" }} +- apiGroups: + - "" + resources: + - namespaces + verbs: + - list + - watch + {{- end}} +- apiGroups: + - mongodb.com + resources: + - mongodb + - mongodb/finalizers + - mongodbusers + - opsmanagers + - opsmanagers/finalizers +{{- if .Values.subresourceEnabled }} + - mongodb/status + - mongodbusers/status + - opsmanagers/status +{{- end }} + verbs: + - "*" + +--- +kind: {{ if eq (.Values.watchNamespace | default "") "*" }} ClusterRoleBinding {{ else }} RoleBinding {{ end }} +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: {{ .Values.name }} + namespace: {{ .Release.Namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: {{ if eq (.Values.watchNamespace | default "") "*" }} ClusterRole {{ else }} Role {{ end }} + name: {{ .Values.name }} +subjects: +- kind: ServiceAccount + name: {{ .Release.Name }}-operator + {{- if .Release.Namespace }} + namespace: {{ .Release.Namespace }} + {{- end }} + + +{{ if .Values.deployValidationWebhooks }} +# This ClusterRoleBinding is necessary in order to use validating +# webhooks—these will prevent you from applying a variety of invalid resource +# definitions. The validating webhooks are optional so this can be removed if +# necessary. +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: {{ .Release.Name }}-{{ .Release.Namespace }}-webhook-binding + namespace: {{ .Release.Namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: mongodb-enterprise-operator-mongodb-webhook +subjects: +- kind: ServiceAccount + name: {{ .Release.Name }}-operator + namespace: {{ .Release.Namespace }} + +--- +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: {{ .Release.Name }}-{{ .Release.Namespace }}-webhook +rules: + - apiGroups: + - "admissionregistration.k8s.io" + resources: + - validatingwebhookconfigurations + verbs: + - get + - create + - update + - delete + +{{ end }} + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: mongodb-enterprise-database-pods + {{- if not (eq (.Values.watchNamespace | default "*") "*") }} + namespace: {{ .Values.watchNamespace }} + {{- else }} + namespace: {{ .Values.namespace }} + {{- end }} +{{- if .Values.registry.imagePullSecrets}} +imagePullSecrets: + - name: {{ .Values.registry.imagePullSecrets }} +{{- end }} +--- diff --git a/production_notes/helm_charts/operator/templates/operator.yaml b/production_notes/helm_charts/operator/templates/operator.yaml new file mode 100644 index 000000000..df9fa16e2 --- /dev/null +++ b/production_notes/helm_charts/operator/templates/operator.yaml @@ -0,0 +1,97 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }}-operator + namespace: {{ .Release.Namespace }} +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/component: controller + app.kubernetes.io/name: {{ .Release.Name }}-operator + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + app.kubernetes.io/component: controller + app.kubernetes.io/name: {{ .Release.Name }}-operator + app.kubernetes.io/instance: {{ .Release.Name }} + annotations: + # lets promethues know to scrape the operator pod and where to scrape them + loadtest.io/scrape_port: "8080" + loadtest.io/should_be_scraped: "true" + spec: + serviceAccountName: {{ .Release.Name }}-operator +{{- if not .Values.managedSecurityContext }} + securityContext: + runAsNonRoot: true + runAsUser: 2000 +{{- end }} +{{- if .Values.registry.imagePullSecrets }} + imagePullSecrets: + - name: {{ .Values.registry.imagePullSecrets | quote }} +{{- end }} + containers: + - name: mongodb-enterprise-operator + image: {{ .Values.registry.operator.Image }}:{{ .Values.registry.operator.Tag | default "latest" }} + imagePullPolicy: {{ .Values.registry.pullPolicy }} + {{- if or .Values.watchOpsManagers .Values.watchDatabase }} + args: + {{- if .Values.watchOpsManager }} + - "-watch-resource=opsmanagers" + {{- end }} + {{- if .Values.watchDatabase }} + - "-watch-resource=mongodb" + - "-watch-resource=mongodbusers" + {{- end }} + command: + - "/usr/local/bin/mongodb-enterprise-operator" + {{- end }} + env: + - name: OPERATOR_ENV + value: {{ .Values.env }} + - name: WATCH_NAMESPACE +{{- if .Values.watchNamespace }} + value: "{{ .Values.watchNamespace }}" +{{- else }} + valueFrom: + fieldRef: + fieldPath: metadata.namespace +{{- end }} + - name: CURRENT_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace +{{- if eq .Values.managedSecurityContext true }} + - name: MANAGED_SECURITY_CONTEXT + value: 'true' +{{- end }} + - name: IMAGE_PULL_POLICY + value: {{ .Values.registry.pullPolicy }} + - name: MONGODB_ENTERPRISE_DATABASE_IMAGE + value: {{ .Values.registry.database.Image }} + - name: DATABASE_VERSION + value: {{ .Values.registry.database.Tag }} + - name: INIT_DATABASE_IMAGE_REPOSITORY + value: {{ .Values.registry.databaseInit.Image }} + - name: INIT_DATABASE_VERSION + value: {{ .Values.registry.databaseInit.Tag }} + - name: OPS_MANAGER_IMAGE_REPOSITORY + value: {{ .Values.registry.opsManager.Image }} + - name: INIT_OPS_MANAGER_IMAGE_REPOSITORY + value: {{ .Values.registry.initOpsManager.Image }} + - name: INIT_OPS_MANAGER_VERSION + value: {{ .Values.registry.initOpsManager.Tag }} + - name: INIT_APPDB_IMAGE_REPOSITORY + value: {{ .Values.registry.initAppDb.Image }} + - name: INIT_APPDB_VERSION + value: {{ .Values.registry.initAppDb.Tag }} + - name: OPS_MANAGER_IMAGE_PULL_POLICY + value: {{ .Values.registry.pullPolicy }} + - name: IMAGE_PULL_POLICY + value: {{ .Values.registry.pullPolicy }} +{{- if .Values.registry.imagePullSecrets }} + - name: IMAGE_PULL_SECRETS + value: {{ .Values.registry.imagePullSecrets }} +{{- end }} diff --git a/production_notes/helm_charts/operator/values.yaml b/production_notes/helm_charts/operator/values.yaml new file mode 100644 index 000000000..0b7a70a6e --- /dev/null +++ b/production_notes/helm_charts/operator/values.yaml @@ -0,0 +1,59 @@ +## Operator + +# Set this to true if your cluster is managing SecurityContext for you. +# If running OpenShift (Cloud, Minishift, etc.), set this to true. +managedSecurityContext: false + +clusterName: cluster.local + +# Name that will be assigned to most of internal Kubernetes objects like Deployment, ServiceAccount, Role etc. +name: mongodb-enterprise-operator + +# Version of mongodb-enterprise-operator and mongodb-enterprise-database images +version: 1.8.0 + +# The Custom Resources that will be watched by the Operator. +# Needs to be changed if only some of the CRDs are installed +watchOpsManager: true +watchDatabase: true + + +# When Operator is deployed globally add a list of namespaces to watch +watchNamespace: [] +# - mongodb + +registry: + + operator: + Image: quay.io/mongodb/mongodb-enterprise-operator + Tag: 1.8.0 + + database: + Image: quay.io/mongodb/mongodb-enterprise-database + Tag: 2.0.1 + + databaseInit: + Image: quay.io/mongodb/mongodb-enterprise-init-database + Tag: 1.0.0 + + opsManager: + Image: quay.io/mongodb/mongodb-enterprise-ops-manager + + initOpsManager: + Image: quay.io/mongodb/mongodb-enterprise-init-ops-manager + Tag: 1.0.2 + + appDb: + Image: quay.io/mongodb/mongodb-enterprise-appdb + + initAppDb: + Image: quay.io/mongodb/mongodb-enterprise-init-appdb + Tag: 1.0.4 + + pullPolicy: Always + +debugPort: + +# Set this to false to disable subresource utilization +# It might be required on some versions of Openshift +subresourceEnabled: true diff --git a/production_notes/helm_charts/opsmanager/.helmignore b/production_notes/helm_charts/opsmanager/.helmignore new file mode 100644 index 000000000..0e8a0eb36 --- /dev/null +++ b/production_notes/helm_charts/opsmanager/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/production_notes/helm_charts/opsmanager/Chart.lock b/production_notes/helm_charts/opsmanager/Chart.lock new file mode 100644 index 000000000..a039c608a --- /dev/null +++ b/production_notes/helm_charts/opsmanager/Chart.lock @@ -0,0 +1,9 @@ +dependencies: +- name: operator + repository: file://../operator + version: 0.0.1 +- name: cert-manager + repository: https://charts.jetstack.io + version: v1.0.4 +digest: sha256:7bac01437a01d8916bf0ed2cd1247bbeeed72c6ec1161fc19891765095d734be +generated: "2020-12-31T13:36:30.436734+01:00" diff --git a/production_notes/helm_charts/opsmanager/Chart.yaml b/production_notes/helm_charts/opsmanager/Chart.yaml new file mode 100644 index 000000000..f5adf4eda --- /dev/null +++ b/production_notes/helm_charts/opsmanager/Chart.yaml @@ -0,0 +1,12 @@ +apiVersion: v2 +name: opsmanager +description: OpsManager + +version: 0.0.1 +dependencies: +- name: operator + version: ">=0.0.1" + repository: "file://../operator" +- name: cert-manager + version: "1.0.4" + repository: https://charts.jetstack.io diff --git a/production_notes/helm_charts/opsmanager/crds b/production_notes/helm_charts/opsmanager/crds new file mode 120000 index 000000000..39982bb7b --- /dev/null +++ b/production_notes/helm_charts/opsmanager/crds @@ -0,0 +1 @@ +../../../public/helm_chart/crds \ No newline at end of file diff --git a/production_notes/helm_charts/opsmanager/templates/ops-manager-global-admin.yaml b/production_notes/helm_charts/opsmanager/templates/ops-manager-global-admin.yaml new file mode 100644 index 000000000..887f227e8 --- /dev/null +++ b/production_notes/helm_charts/opsmanager/templates/ops-manager-global-admin.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Values.name }}-global-admin + namespace: {{ .Release.Namespace }} + labels: + "helm.sh/chart": {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} +type: Opaque +data: + Username: {{ .Values.globalAdmin | b64enc }} + Password: {{ .Values.globalAdminPassword | b64enc }} + FirstName: {{ .Values.globalAdminFirstName | b64enc }} + LastName: {{ .Values.globalAdminLastName | b64enc }} diff --git a/production_notes/helm_charts/opsmanager/templates/ops-manager.yaml b/production_notes/helm_charts/opsmanager/templates/ops-manager.yaml new file mode 100644 index 000000000..cb58826e8 --- /dev/null +++ b/production_notes/helm_charts/opsmanager/templates/ops-manager.yaml @@ -0,0 +1,65 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDBOpsManager +metadata: + name: {{ .Release.Name }}-ops-manager + namespace: {{ .Release.Namespace }} + labels: + "helm.sh/chart": {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + "app.kubernetes.io/managed-by": {{ .Release.Service }} + +spec: + replicas: {{ .Values.replicas | default 1 }} + version: {{ .Values.version | default "4.4.2" }} + adminCredentials: {{ .Values.name }}-global-admin + persistent: true + + # For the full list of options see https://docs.opsmanager.mongodb.com/current/reference/configuration/index.html + configuration: + # passing mms.ignoreInitialUiSetup=true allows to avoid the setup wizard in Ops Manager. Note, that + # this requires to set some mandatory configuration properties, see + # https://docs.opsmanager.mongodb.com/current/reference/configuration/index.html#mms.ignoreInitialUiSetup + # automation.versions.source: local + mms.ignoreInitialUiSetup: "true" + mms.adminEmailAddr: {{ .Values.mail.adminEmailAddr | quote }} + mms.fromEmailAddr: {{ .Values.mail.adminEmailAddr | quote }} + mms.replyToEmailAddr: {{ .Values.mail.adminEmailAddr | quote }} + mms.mail.hostname: {{ .Values.mail.hostname | quote }} + mms.mail.port: {{ .Values.mail.port | quote }} + mms.mail.ssl: {{ .Values.mail.ssl | quote }} + mms.mail.transport: {{ .Values.mail.transport | quote }} + mms.minimumTLSVersion: {{ .Values.minimumTLSVersion | quote }} + mms.publicApi.whitelistEnabled: {{ .Values.publicApi.whitelistEnabled | quote }} + + backup: + enabled: false + + applicationDatabase: + version: "4.4.0-ent" + members: 3 + + statefulSet: + spec: + # volumeClaimTemplates: + # - metadata: + # name: mongodb-versions + # spec: + # accessModes: [ "ReadWriteOnce" ] + # resources: + # requests: + # storage: 10Gi + template: + spec: + containers: + - name: mongodb-ops-manager + # volumeMounts: + # - name: mongodb-versions + # this is the directory in each Pod where all MongoDB + # archives must be put + # mountPath: /mongodb-ops-manager/mongodb-releases + resources: + requests: + memory: 15G + limits: + memory: 15G + diff --git a/production_notes/helm_charts/opsmanager/templates/opsmanager-roles.yaml b/production_notes/helm_charts/opsmanager/templates/opsmanager-roles.yaml new file mode 100644 index 000000000..82d38a6d8 --- /dev/null +++ b/production_notes/helm_charts/opsmanager/templates/opsmanager-roles.yaml @@ -0,0 +1,52 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: mongodb-enterprise-appdb + namespace: {{ .Release.Namespace }} + labels: + "helm.sh/chart": {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + + + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: mongodb-enterprise-ops-manager + namespace: {{ .Release.Namespace }} + labels: + "helm.sh/chart": {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + +--- +kind: Role +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: mongodb-enterprise-appdb + namespace: {{ .Release.Namespace }} + labels: + "helm.sh/chart": {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} +rules: + - apiGroups: + - "" + resources: + - secrets + verbs: + - get + +--- +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: mongodb-enterprise-appdb + namespace: {{ .Release.Namespace }} + labels: + "helm.sh/chart": {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: mongodb-enterprise-appdb +subjects: + - kind: ServiceAccount + name: mongodb-enterprise-appdb + namespace: {{ .Release.Namespace }} diff --git a/production_notes/helm_charts/opsmanager/values.yaml b/production_notes/helm_charts/opsmanager/values.yaml new file mode 100644 index 000000000..fbaa7ab29 --- /dev/null +++ b/production_notes/helm_charts/opsmanager/values.yaml @@ -0,0 +1,34 @@ +--- +name: ops-manager +replicas: 1 +version: "4.4.4" + + +# Kubernetes internal DNS +clusterName: cluster.local + + +# Ops Manager Global Admin user name and password. +# Ensure it complies with OpsManager password format +globalAdmin: "test@test.com" +globalAdminPassword: "KubeTest!1" +globalAdminFirstName: "First Name" +globalAdminLastName: "Last Name" + +# enable Access Lists for Ops Manager +publicApi: + whitelistEnabled: false + + +# Required: SMTP Mail server set up for password recovery +mail: + adminEmailAddr: "support@example.com" + hostname: "email-smtp.us-east-1.amazonaws.com" + port: "465" + ssl: "true" + transport: "smtp" + + +cert-manager: + installCRDs: true + fullnameOverride: "cert-manager" diff --git a/production_notes/helm_charts/ycsb/.helmignore b/production_notes/helm_charts/ycsb/.helmignore new file mode 100644 index 000000000..50af03172 --- /dev/null +++ b/production_notes/helm_charts/ycsb/.helmignore @@ -0,0 +1,22 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/production_notes/helm_charts/ycsb/Chart.yaml b/production_notes/helm_charts/ycsb/Chart.yaml new file mode 100644 index 000000000..342d46c35 --- /dev/null +++ b/production_notes/helm_charts/ycsb/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v2 +name: ycsb +description: YCSB helm chart +version: 0.0.1 diff --git a/production_notes/helm_charts/ycsb/ca_tls.crt b/production_notes/helm_charts/ycsb/ca_tls.crt new file mode 120000 index 000000000..97de7dbb9 --- /dev/null +++ b/production_notes/helm_charts/ycsb/ca_tls.crt @@ -0,0 +1 @@ +../mongodb/certs/ca_tls.crt \ No newline at end of file diff --git a/production_notes/helm_charts/ycsb/templates/job.yaml b/production_notes/helm_charts/ycsb/templates/job.yaml new file mode 100644 index 000000000..a67b4c81a --- /dev/null +++ b/production_notes/helm_charts/ycsb/templates/job.yaml @@ -0,0 +1,63 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ .Release.Name }}-ycsb-job +spec: +{{ if eq .Values.action "run" }} + parallelism: {{ .Values.parallelism }} +{{ else }} + parallelism: 1 +{{ end }} + template: + metadata: + name: {{ .Release.Name }}-ycsb-job + namespace: {{ .Release.Namespace }} + spec: + restartPolicy: OnFailure + volumes: + - name: workload-volume + configMap: + name: {{ .Release.Name }}-workload +{{- if .Values.tls }} + - name: tls-cert + secret: + secretName: ycsb-cert +{{end}} + containers: + - name: ycsb-run + {{- if .Values.tls }} + image: bznein/ycsb:latest + {{else}} + image: jmimick/ycsb:latest + {{end}} + imagePullPolicy: Always + volumeMounts: + - name: workload-volume + mountPath: /work + {{- if .Values.tls }} + - name: tls-cert + readOnly: true + mountPath: "/etc/tls-cert" + {{end}} + env: + - name: DB + value: {{ .Values.db }} + - name: ACTION + value: {{ .Values.action }} + - name: USERNAME + valueFrom: + secretKeyRef: + name: {{ .Values.binding }} + key: username + optional: true + - name: PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.binding }} + key: password + optional: true + - name: URI + valueFrom: + secretKeyRef: + name: {{ .Values.binding }} + key: uri diff --git a/production_notes/helm_charts/ycsb/templates/workload.configmap.yaml b/production_notes/helm_charts/ycsb/templates/workload.configmap.yaml new file mode 100644 index 000000000..7fb886713 --- /dev/null +++ b/production_notes/helm_charts/ycsb/templates/workload.configmap.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Release.Name }}-workload + namespace: {{ .Release.Namespace }} +data: + workload: | +{{ .Values.workloadDefaults | indent 4 }} +{{- range $key, $value := .Values.workload }} + # workload overrides +{{ $key | indent 4 }}={{ $value }} +{{- end }} diff --git a/production_notes/helm_charts/ycsb/templates/ycsb_secret.yaml b/production_notes/helm_charts/ycsb/templates/ycsb_secret.yaml new file mode 100644 index 000000000..5a092b756 --- /dev/null +++ b/production_notes/helm_charts/ycsb/templates/ycsb_secret.yaml @@ -0,0 +1,9 @@ +{{- if .Values.tls }} +apiVersion: v1 +kind: Secret +metadata: + name: ycsb-cert + namespace: {{ .Release.Namespace }} +data: + ca.crt: {{ .Files.Get "ca_tls.crt" | b64enc}} +{{end}} diff --git a/production_notes/helm_charts/ycsb/values.yaml b/production_notes/helm_charts/ycsb/values.yaml new file mode 100644 index 000000000..e748938ad --- /dev/null +++ b/production_notes/helm_charts/ycsb/values.yaml @@ -0,0 +1,23 @@ +db: "mongodb" # Or "mongodb-async", no other values supported! +action: "load" # Or "run" +binding: + +tls: true + +parallelism: 1 + +workloadDefaults: |+ + recordcount=9000000 + operationcount=9000000 + workload=site.ycsb.workloads.CoreWorkload + + readallfields=true + + readproportion=0.1 + updateproportion=0.1 + scanproportion=0 + insertproportion=0.8 + fieldlength=1000 + requestdistribution=zipfian + + table=ycsb diff --git a/production_notes/helm_charts/ycsb/workload-a.yaml b/production_notes/helm_charts/ycsb/workload-a.yaml new file mode 100644 index 000000000..c1dc302fc --- /dev/null +++ b/production_notes/helm_charts/ycsb/workload-a.yaml @@ -0,0 +1,24 @@ +# Default values for ycsb-benchmark. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +binding: +db: "mongodb" # Or "mongodb-async", no other values supported! + +parallelism: 8 + +workloadDefaults: |+ + recordcount=1000000 + operationcount=10000000 + #batchsize=1000 + workload=site.ycsb.workloads.CoreWorkload + readallfields=true + readproportion=0.5 + updateproportion=0.5 + scanproportion=0 + insertproportion=0 + requestdistribution=zipfian + table=workload-a + fieldcount=50 + fieldlength=250 + threadcount=16 diff --git a/production_notes/monitoring/00-ns.yaml b/production_notes/monitoring/00-ns.yaml new file mode 100644 index 000000000..ff7ae1b93 --- /dev/null +++ b/production_notes/monitoring/00-ns.yaml @@ -0,0 +1,5 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: monitoring diff --git a/production_notes/monitoring/02-role.yaml b/production_notes/monitoring/02-role.yaml new file mode 100644 index 000000000..80643a005 --- /dev/null +++ b/production_notes/monitoring/02-role.yaml @@ -0,0 +1,40 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: prometheus +rules: +- apiGroups: [""] + resources: + - nodes + - nodes/proxy + - services + - endpoints + - pods + verbs: ["get", "list", "watch"] +- apiGroups: [""] + resources: + - configmaps + verbs: ["get"] +- nonResourceURLs: ["/metrics"] + verbs: ["get"] +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: prometheus + namespace: monitoring +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRoleBinding +metadata: + name: prometheus +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: prometheus +subjects: +- kind: ServiceAccount + name: prometheus + namespace: monitoring +--- diff --git a/production_notes/monitoring/03-pvc.yaml b/production_notes/monitoring/03-pvc.yaml new file mode 100644 index 000000000..f7c8f73fc --- /dev/null +++ b/production_notes/monitoring/03-pvc.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: prometheus-pvc + namespace: monitoring +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi diff --git a/production_notes/monitoring/04-cm.yaml b/production_notes/monitoring/04-cm.yaml new file mode 100644 index 000000000..5dab8b73e --- /dev/null +++ b/production_notes/monitoring/04-cm.yaml @@ -0,0 +1,67 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: prometheus-configmap + namespace: monitoring +data: + prometheus.yaml: | + global: + scrape_interval: 10s + scrape_timeout: 10s + evaluation_interval: 10s + scrape_configs: + - job_name: 'kubernetes-cadvisor' + scheme: https + metrics_path: /metrics/cadvisor + tls_config: + ca_file: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt + # If your node certificates are self-signed or use a different CA to the + # master CA, then disable certificate verification below. Note that + # certificate verification is an integral part of a secure infrastructure + # so this should only be disabled in a controlled environment. You can + # disable certificate verification by uncommenting the line below. + # + # insecure_skip_verify: true + bearer_token_file: /var/run/secrets/kubernetes.io/serviceaccount/token + + kubernetes_sd_configs: + - role: node + # https://github.com/prometheus/prometheus/issues/2613 + relabel_configs: + - action: labelmap + regex: __meta_kubernetes_node_label_(.+) + - source_labels: [__address__] + action: replace + target_label: __address__ + regex: ([^:;]+):(\d+) + replacement: ${1}:10255 + - source_labels: [__scheme__] + action: replace + target_label: __scheme__ + regex: https + replacement: http + - job_name: 'kubernetes-pods' + scheme: http + kubernetes_sd_configs: + - role: pod + + relabel_configs: + # only scrape metrics from pods which have the annotation "loadtest.io/should_be_scraped=true" + - source_labels: [__meta_kubernetes_pod_annotation_loadtest_io_should_be_scraped] + action: keep + regex: true + # specify the target port at which prometheus should scrape for pod metrics + - source_labels: [__address__, __meta_kubernetes_pod_annotation_loadtest_io_scrape_port] + action: replace + regex: ([^:]+)(?::\d+)?;(\d+) + replacement: $1:$2 + target_label: __address__ + - action: labelmap + regex: __meta_kubernetes_pod_label_(.+) + - source_labels: [__meta_kubernetes_namespace] + action: replace + target_label: kubernetes_namespace + - source_labels: [__meta_kubernetes_pod_name] + action: replace + target_label: kubernetes_pod_name + diff --git a/production_notes/monitoring/05-deployment.yaml b/production_notes/monitoring/05-deployment.yaml new file mode 100644 index 000000000..75935d566 --- /dev/null +++ b/production_notes/monitoring/05-deployment.yaml @@ -0,0 +1,56 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: prometheus-core + namespace: monitoring + labels: + app: prometheus + component: core +spec: + replicas: 1 + selector: + matchLabels: + app: prometheus + template: + metadata: + name: prometheus-main + labels: + app: prometheus + component: core + spec: + serviceAccountName: prometheus + containers: + - name: prometheus + image: prom/prometheus:v1.7.0 + args: + - '-storage.local.retention=17h' + - '-storage.local.memory-chunks=500000' + - '-config.file=/etc/prometheus/prometheus.yaml' + ports: + - name: webui + containerPort: 9090 + resources: + requests: + cpu: 500m + memory: 2G + limits: + cpu: 500m + memory: 3G + volumeMounts: + - name: config-volume + mountPath: /etc/prometheus + - name: data + mountPath: /prometheus + # needed when we add prometheus alerting/recording rules + # - name: rules-volume + # mountPath: /etc/prometheus-rules + volumes: + - name: config-volume + configMap: + name: prometheus-configmap + - name: data + persistentVolumeClaim: + claimName: prometheus-pvc + # - name: rules-volume + # configMap: + # name: prometheus-rules diff --git a/production_notes/monitoring/06-svc-int.yaml b/production_notes/monitoring/06-svc-int.yaml new file mode 100644 index 000000000..52101f9fa --- /dev/null +++ b/production_notes/monitoring/06-svc-int.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: prometheus-int + namespace: monitoring + labels: + app: prometheus + component: core + annotations: + prometheus.io/scrape: 'true' +spec: + type: ClusterIP + ports: + - port: 8080 + targetPort: 9090 + protocol: TCP + selector: + app: prometheus + component: core diff --git a/production_notes/monitoring/07-grafana-pvc.yaml b/production_notes/monitoring/07-grafana-pvc.yaml new file mode 100644 index 000000000..e09825659 --- /dev/null +++ b/production_notes/monitoring/07-grafana-pvc.yaml @@ -0,0 +1,11 @@ +kind: PersistentVolumeClaim +apiVersion: v1 +metadata: + name: grafana-pvc + namespace: monitoring +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 2Gi diff --git a/production_notes/monitoring/08-grafana-dep.yaml b/production_notes/monitoring/08-grafana-dep.yaml new file mode 100644 index 000000000..3da565446 --- /dev/null +++ b/production_notes/monitoring/08-grafana-dep.yaml @@ -0,0 +1,31 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: grafana + namespace: monitoring +spec: + replicas: 1 + selector: + matchLabels: + app: grafana + template: + metadata: + labels: + app: grafana + spec: + containers: + - image: grafana/grafana + name: grafana + ports: + - containerPort: 3000 + name: http + volumeMounts: + - name: grafana-pvc + mountPath: /var/lib/grafana + volumes: + - name: grafana-pvc + persistentVolumeClaim: + claimName: grafana-pvc + securityContext: + # https://github.com/grafana/grafana-docker/issues/167#issuecomment-391975926 + fsGroup: 472 diff --git a/production_notes/monitoring/09-grafana-svc.yaml b/production_notes/monitoring/09-grafana-svc.yaml new file mode 100644 index 000000000..3c72c7f59 --- /dev/null +++ b/production_notes/monitoring/09-grafana-svc.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: grafana + namespace: monitoring + labels: + app: grafana + annotations: + prometheus.io/scrape: 'true' +spec: + type: LoadBalancer + ports: + - port: 8080 + targetPort: 3000 + protocol: TCP + selector: + app: grafana diff --git a/production_notes/monitoring/10-prom-ext.yaml b/production_notes/monitoring/10-prom-ext.yaml new file mode 100644 index 000000000..7a322986d --- /dev/null +++ b/production_notes/monitoring/10-prom-ext.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: prometheus-ext + namespace: monitoring + labels: + app: prometheus + component: core + annotations: + prometheus.io/scrape: 'true' +spec: + type: LoadBalancer + ports: + - port: 8080 + targetPort: 9090 + protocol: TCP + selector: + app: prometheus + component: core diff --git a/production_notes/pkg/monitor/monitor.go b/production_notes/pkg/monitor/monitor.go new file mode 100644 index 000000000..70ce295ab --- /dev/null +++ b/production_notes/pkg/monitor/monitor.go @@ -0,0 +1,133 @@ +package monitor + +import ( + "context" + "sync" + "time" + + "log" + + api "github.com/prometheus/client_golang/api" + v1 "github.com/prometheus/client_golang/api/prometheus/v1" + "github.com/prometheus/common/model" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes" +) + +type Monitor struct { + PromClient api.Client + KubeClient *kubernetes.Clientset + Timeout time.Duration + StartTime time.Time + EndTime time.Time + sync.RWMutex +} + +func max(t1, t2 time.Time) time.Time { + if t1.After(t2) { + return t1 + } + return t2 +} + +func isStatefulSetReady(c kubernetes.Clientset, stsName, namespace string) wait.ConditionFunc { + return func() (bool, error) { + log.Printf("waiting for statefulset %s to be in ready state...\n", stsName) + + sts, err := c.AppsV1().StatefulSets(namespace).Get(context.TODO(), stsName, metav1.GetOptions{}) + // wait for the operator to create sts + if err != nil && errors.IsNotFound(err) { + return false, nil + } + if err != nil { + return false, err + } + + return sts.Status.Replicas == sts.Status.ReadyReplicas, nil + } +} + +func waitForStatefulsetReady(c kubernetes.Clientset, stsName, namespace string, timeout time.Duration) error { + return wait.PollImmediate(time.Second, timeout, isStatefulSetReady(c, stsName, namespace)) +} + +// monitorStats monitors and reports the stats of 1. Reconcile time of operator, 2. Time to ready for MongoDB replicaset and 3. CPU and Memory usage of Operator +func (m *Monitor) MonitorReplicaSets(ctx context.Context, replicasetName string) { + + err := waitForStatefulsetReady(*m.KubeClient, replicasetName, "mongodb", m.Timeout) + if err != nil { + log.Printf("error in monitoring replicaset: %v", err) + return + } + + t2 := time.Now() + m.Lock() + m.EndTime = max(m.EndTime, t2) + m.Unlock() + +} + +// MonitorOperatorReconcileTime measures the reconcile_time of the operator from the metrics being exposed +// by controller-runtime duration is the time duration over which we would like to measure the metrics, +// it's the minimum of the mongodbReplicaset becoming "ready" and the "wait" time. +func (m *Monitor) MonitorOperatorReconcileTime(ctx context.Context) { + // Currently it only measures p50(median), the following needs to be converted into + // a function as we measure p90, p95 etc + queryString := "histogram_quantile(0.5, rate(controller_runtime_reconcile_time_seconds_bucket{controller=\"mongodbreplicaset-controller\"}[5m]))" + + result, err := performQuery(ctx, m.PromClient, queryString, m.StartTime, m.EndTime) + if err != nil { + log.Print(err.Error()) + } else { + log.Printf("operator Reconcile time metrics p50: %v", result) + } +} + +// MonitorOperatorResourceUsage measures the operator CPU/Memory by querying the prometheus server. +// The duration over which it measures the metrics is the minimum of the "time-duration" it takes +// for the mongod Replicaset to reach a "ready" state or the specified timeout +func (m *Monitor) MonitorOperatorResourceUsage(ctx context.Context) { + + // specify pod name since we will be having only one pod corresponsing to the operator + CPUQueryString := "sum(rate(container_cpu_usage_seconds_total{namespace=\"mongodb\", pod=~\"om-operator-.*\"}[2m])) by (pod) * 1000" + + CPUResults, err := performQuery(ctx, m.PromClient, CPUQueryString, m.StartTime, m.EndTime) + if err != nil { + log.Print(err.Error()) + } else { + log.Printf("cpu resource metrics: %v", CPUResults) + } + + MemoryQueryString := "sum(container_memory_usage_bytes{namespace=\"mongodb\", pod=~\"om-operator-.*\"}) by (pod) / 1000000 " + MemoryResults, err := performQuery(ctx, m.PromClient, MemoryQueryString, m.StartTime, m.EndTime) + if err != nil { + log.Print(err.Error()) + } else { + log.Printf("memory Resource metrics: %v", MemoryResults) + } +} + +func performQuery(ctx context.Context, promClient api.Client, queryString string, s time.Time, e time.Time) (model.Value, error) { + + v1api := v1.NewAPI(promClient) + + r := v1.Range{ + Start: s, + End: e, + Step: time.Minute, + } + + results, warnings, err := v1api.QueryRange(ctx, queryString, r) + if err != nil { + log.Printf("Error querying Prometheus: %v\n", err) + return nil, err + } + + if len(warnings) > 0 { + log.Printf("Warnings: %v\n", warnings) + } + // TODO: Persist the result, upload this to S3(or something) when we increase the replicaset count + return results, nil +} diff --git a/production_notes/pkg/provisioner/provisioner.go b/production_notes/pkg/provisioner/provisioner.go new file mode 100644 index 000000000..0a033b687 --- /dev/null +++ b/production_notes/pkg/provisioner/provisioner.go @@ -0,0 +1,69 @@ +package provisioner + +import ( + "fmt" + "log" + "os/exec" + "strings" +) + +func checkClusterExists(clusterName string) (bool, error) { + cmd := exec.Command("kops", "get", "cluster", clusterName) + out, err := cmd.CombinedOutput() + if err == nil { + // No error means kops get cluster completed succesfully + return true, nil + } + // Need to dinstinguish between error "cluster not found" + // and other errors + if strings.Contains(string(out), "cluster not found") { + return false, nil + } + return false, err +} + +func execWithOutputAndReturnError(cmd *exec.Cmd) error { + log.Printf(cmd.String()) + out, err := cmd.CombinedOutput() + log.Printf(string(out)) + return err +} + +func DeleteIfExists(clusterName string) error { + clusterExists, err := checkClusterExists(clusterName) + if err != nil { + return err + } + if !clusterExists { + return nil + } + log.Printf("Deleting cluster %s", clusterName) + cmd := exec.Command("kops", "delete", "cluster", clusterName, "--yes") + return execWithOutputAndReturnError(cmd) +} + +func CreateCluster(clusterName string, nodeSize string, networking string) error { + var cni string + if networking != "" { + cni = fmt.Sprintf("--networking=%s", networking) + } + + createCmd := exec.Command("kops", "create", "cluster", clusterName, "--node-size", nodeSize, "--zones=eu-west-2a", "--node-count=4", "--node-volume-size=40", "--master-size=t2.medium", "--master-volume-size=16", "--ssh-public-key=~/.ssh/id_rsa.pub", "--authorization=RBAC", "--kubernetes-version=1.18.10", cni) + err := execWithOutputAndReturnError(createCmd) + if err != nil { + return err + } + + updateCmd := exec.Command("kops", "update", "cluster", clusterName, "--yes") + return execWithOutputAndReturnError(updateCmd) +} + +func WaitForClusterToBeReady(clusterName string) error { + validateCmd := exec.Command("kops", "validate", "cluster", clusterName, "--wait", "20m") + return execWithOutputAndReturnError(validateCmd) +} + +func ExportKubecfg(clusterName string, path string) error { + cmd := exec.Command("kops", "export", "kubecfg", "--kubeconfig", path, clusterName) + return execWithOutputAndReturnError(cmd) +} diff --git a/production_notes/pkg/s3/s3.go b/production_notes/pkg/s3/s3.go new file mode 100644 index 000000000..c4c0c8455 --- /dev/null +++ b/production_notes/pkg/s3/s3.go @@ -0,0 +1,38 @@ +package s3 + +import ( + "bytes" + "context" + "fmt" + "net/http" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/google/uuid" +) + +const ( + S3_REGION = "eu-west-2" + S3_BUCKET = "kube-load-testing" +) + +func NewS3Session() (*session.Session, error) { + return session.NewSession(&aws.Config{Region: aws.String(S3_REGION)}) +} + +// UploadFile uploads the specified content to the key_prefix folder path in the s3 bucket +func UploadFile(ctx context.Context, s *session.Session, content, key_prefix string) error { + buffer := []byte(content) + // append with uuid to have unique file names for each test run + suffix := uuid.New().String() + + _, err := s3.New(s).PutObject(&s3.PutObjectInput{ + Bucket: aws.String(S3_BUCKET), + Key: aws.String(fmt.Sprintf("%s/%s", key_prefix, suffix)), + ACL: aws.String("private"), + Body: bytes.NewReader(buffer), + ContentType: aws.String(http.DetectContentType(buffer)), + }) + return err +} diff --git a/production_notes/pkg/ycsb/ycsb.go b/production_notes/pkg/ycsb/ycsb.go new file mode 100644 index 000000000..6a3ca1e9c --- /dev/null +++ b/production_notes/pkg/ycsb/ycsb.go @@ -0,0 +1,76 @@ +package ycsb + +import ( + "context" + "fmt" + "log" + "os/exec" + "strings" + + "github.com/10gen/ops-manager-kubernetes/production_notes/pkg/s3" + "golang.org/x/xerrors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +func getPodNameForJob(ctx context.Context, c kubernetes.Clientset, jobName, namespace string) (string, error) { + labelSelector := fmt.Sprintf("job-name=%s", jobName) + + ops := metav1.ListOptions{ + LabelSelector: labelSelector, + } + + pod, err := c.CoreV1().Pods(namespace).List(context.TODO(), ops) + if err != nil { + return "", err + } + + if len(pod.Items) != 1 { + return "", xerrors.Errorf("more than one or zero pod found with job selector: %w", labelSelector) + } + + return pod.Items[0].ObjectMeta.Name, nil +} + +// from returns the string in "str" from "pattern" has been found +func from(str, pattern string) string { + pos := strings.Index(str, pattern) + if pos == -1 { + return "" + } + if pos >= len(str) { + return "" + } + return str[pos:] +} + +func ParseAndUploadYCSBPodLogs(ctx context.Context, c kubernetes.Clientset, namespace, jobName string) error { + podName, err := getPodNameForJob(ctx, c, jobName, namespace) + if err != nil { + return err + } + + cmd := exec.Command("kubectl", "logs", podName) + + output, err := cmd.CombinedOutput() + if err != nil { + return xerrors.Errorf("%s", output) + } + + results := from(string(output), "[OVERALL]") + log.Printf("ycsb results: %s", results) + + // Upload ycsb the data to S3 + s, err := s3.NewS3Session() + if err != nil { + return xerrors.Errorf("error while creating s3 session: %w", err) + } + + err = s3.UploadFile(ctx, s, results, "ycsb") + if err != nil { + return xerrors.Errorf("error while uploading to s3: %w", err) + } + + log.Printf("successfully uploaded ycsb results to s3") + return nil +} diff --git a/public/.github/ISSUE_TEMPLATE/bug_report.md b/public/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..c442f56fd --- /dev/null +++ b/public/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,46 @@ +--- +name: Bug report +about: File a report about a problem with the Operator +title: '' +labels: '' +assignees: '' + +--- +**What did you do to encounter the bug?** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**What did you expect?** +A clear and concise description of what you expected to happen. + +**What happened instead?** +A clear and concise description of what happened instead + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Operator Information** + - Operator Version + - Base Images used (ubuntu or UBI) + +**Ops Manager Information** + - Ops Manager Version + - Is Ops Manager managed by the Operator or not? + - Is Ops Manager in Local Mode? + +**Kubernetes Cluster Information** + - Distribution: + - Version: + - Image Registry location (quay, or an internal registry) + +**Additional context** +Add any other context about the problem here. + +If possible, please include: + - `kubectl describe` output + - yaml definitions for your objects + - log files for the operator, database pods and Ops Manager + - An [Ops Manager Diagnostic Archive](https://docs.opsmanager.mongodb.com/current/tutorial/retrieve-debug-diagnostics) diff --git a/public/.github/ISSUE_TEMPLATE/config.yml b/public/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..ea996e736 --- /dev/null +++ b/public/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: MongoDB Support + url: https://support.mongodb.com + about: Please use the Support Center to receive official support within a timeline. Use this for urgent requests. + - name: MongoDB Feedback + url: https://feedback.mongodb.com/forums/924355-ops-tools + about: Use our Feedback page for making feature requests. diff --git a/public/.github/PULL_REQUEST_TEMPLATE.md b/public/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..4f3aec72a --- /dev/null +++ b/public/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,7 @@ +### All Submissions: + +* [ ] Have you opened an Issue before filing this PR? +* [ ] Have you signed our [CLA](https://www.mongodb.com/legal/contributor-agreement)? +* [ ] Have you checked to ensure there aren't other open [Pull Requests](../../../pulls) for the same update/change? +* [ ] Put `closes #XXXX` in your comment to auto-close the issue that your PR fixes (if such). + diff --git a/public/.github/workflows/release-multicluster-cli.yaml b/public/.github/workflows/release-multicluster-cli.yaml new file mode 100644 index 000000000..405974a52 --- /dev/null +++ b/public/.github/workflows/release-multicluster-cli.yaml @@ -0,0 +1,27 @@ +name: Release multicluster-cli binary +on: + push: + tags: + - '*' +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: 1.20 + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v4 + with: + distribution: goreleaser + version: latest + args: release --rm-dist + workdir: ./tools/multicluster + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GORELEASER_CURRENT_TAG: ${{ github.ref_name }} diff --git a/public/LICENSE b/public/LICENSE new file mode 100644 index 000000000..5adc32d72 --- /dev/null +++ b/public/LICENSE @@ -0,0 +1,2 @@ +Usage of the MongoDB Enterprise Operator for Kubernetes indicates agreement with the MongoDB Development, Test, and Evaluation Agreement +https://www.mongodb.com/legal/evaluation-agreement diff --git a/public/README.md b/public/README.md new file mode 100644 index 000000000..7ce31378d --- /dev/null +++ b/public/README.md @@ -0,0 +1,200 @@ +# MongoDB Enterprise Kubernetes Operator # + +Welcome to the MongoDB Enterprise Kubernetes Operator. The Operator enables easy deploy of the following applications into Kubernetes clusters: +* MongoDB - Replica Sets, Sharded Clusters and Standalones - with authentication, TLS and many more options. +* Ops Manager - our enterprise management, monitoring and backup platform for MongoDB. The Operator can install and manage Ops Manager in Kubernetes for you. Ops Manager can manage MongoDB instances both inside and outside Kubernetes. + +The Operator requires access to one of our database management tools - Ops Manager or Cloud Manager - to deploy MongoDB instances. You may run Ops Manager either inside or outside Kubernetes, or may use Cloud Manager (cloud.mongodb.com) instead. + +This is an Enterprise product, available under the Enterprise Advanced license. +We also have a [Community Operator](https://github.com/mongodb/mongodb-kubernetes-operator). + +## Support, Feature Requests and Community ## + +The Enterprise Operator is supported by the [MongoDB Support Team](https://support.mongodb.com/). If you need help, please file a support ticket. +If you have a feature request, you can make one on our [Feedback Site](https://feedback.mongodb.com/forums/924355-ops-tools) + +You can discuss this integration in our new [Community Forum](https://developer.mongodb.com/community/forums/) - please use the tag [kubernetes-operator](https://developer.mongodb.com/community/forums/tag/kubernetes-operator) + +## Videos ## + +Here are some talks from MongoDB Live 2020 about the Operator: +* [Kubernetes, MongoDB, and Your MongoDB Data Platform](https://www.youtube.com/watch?v=o1fUPIOdKeU) +* [Run it in Kubernetes! Community and Enterprise MongoDB in Containers](https://www.youtube.com/watch?v=2Xszdg-4T6A) + +## Documentation ## + +[Install Kubernetes Operator](https://docs.opsmanager.mongodb.com/current/tutorial/install-k8s-operator) + +[Deploy MongoDB](https://docs.mongodb.com/kubernetes-operator/stable/mdb-resources/) + +[Deploy Ops Manager](https://docs.mongodb.com/kubernetes-operator/stable/om-resources/) + +[MongoDB Resource Specification](https://docs.opsmanager.mongodb.com/current/reference/k8s-operator-specification) + +[Ops Manager Resource Specification](https://docs.mongodb.com/kubernetes-operator/stable/reference/k8s-operator-om-specification/) + +[Troubleshooting Kubernetes Operator](https://docs.opsmanager.mongodb.com/current/reference/troubleshooting/k8s/) + +[Known Issues for Kubernetes Operator](https://docs.mongodb.com/kubernetes-operator/stable/reference/known-issues/) + +## Requirements ## + +Please refer to the [Installation Instructions](https://docs.mongodb.com/kubernetes-operator/stable/tutorial/plan-k8s-operator-install/) +to see which Kubernetes and Openshift versions the Operator is compatible with + +To work with MongoDB resource this Operator requires [Ops Manager](https://docs.opsmanager.mongodb.com/current/) (Ops Manager can +be installed into the same Kubernetes cluster by the Operator or installed outside of the cluster manually) +or [Cloud Manager](https://cloud.mongodb.com/user#/cloud/login). +> If this is your first time trying the Operator, Cloud Manager is easier to get started. Log in, and create 'Cloud Manager' Organizations and Projects to use with the Operator. + + +## Installation + +### Create Kubernetes Namespace + +The Mongodb Enterprise Operator is installed, into the `mongodb` namespace by default, but this namespace is not created automatically. To create this namespace you should execute: + + kubectl create namespace mongodb + +To use a different namespace, update the yaml files' `metadata.namespace` attribute to point to your preferred namespace. If using `helm` you need to override the `namespace` attribute with `--set namespace=<..>` during helm installation. + +### Installation using yaml files + +#### Create CustomResourceDefinitions + +`CustomResourceDefinition`s (or `CRDs`) are Kubernetes Objects which can be used to instruct the Operators to perform operations on your Kubernetes cluster. Our CRDs control MongoDB and Ops Manager deployments. They should be installed before installing the Operator. +CRDs are defined cluster-wide, so to install them, you must have Cluster-level access. However, once the CRDs are installed, MongoDB instances can be deployed with namespace-level access only. + + kubectl apply -f https://raw.githubusercontent.com/mongodb/mongodb-enterprise-kubernetes/master/crds.yaml + +#### Operator Installation + +> In order to install the Operator in OpenShift, please follow [these](openshift-install.md) instructions instead. + +To install the Operator using yaml files, you may apply the config directly from github; + + kubectl apply -f https://raw.githubusercontent.com/mongodb/mongodb-enterprise-kubernetes/master/mongodb-enterprise.yaml + +or can clone this repo, make any edits you need, and apply it from disk: + + kubectl apply -f mongodb-enterprise.yaml + +### Installation using the Helm Chart + +MongoDB's official Helm Charts are hosted at https://github.com/mongodb/helm-charts + +## MongoDB Resource ## + +*This section describes how to deploy MongoDB instances. This requires a working Ops or Cloud Manager installation. See below for instructions on how to configure Ops Manager.* + +### Adding Ops Manager Credentials ### + +For the Operator to work, you will need the following information: + +* Base URL - the URL of an Ops Manager instance (for Cloud Manager use `https://cloud.mongodb.com`) +* (optional) Project Name - the name of an Ops Manager Project for MongoDB instances to be deployed into. This project will be created by the Operator if it doesn't exist. We recommend that you allow the Operator to create and manage the projects it uses. By default, the Operator will use the name of the MongoDB resource as the project name. +* (optional) Organization ID - the ID of the Organization which the Project belongs to. By default, the Operator will create an Organization with the same name as the Project. +* API Credentials. This can be any pair of: + * Public and Private Programmatic API keys. They correspond to `user` and `publicApiKey` fields in the Secret storing +credentials. More information about the way to create them using Ops Manager UI can be found +[here](https://docs.opsmanager.mongodb.com/current/tutorial/configure-public-api-access/#programmatic-api-keys) + * Username and Public API key. More information about the way to create them using Ops Manager UI can be found + [here](https://docs.opsmanager.mongodb.com/current/tutorial/configure-public-api-access/#personal-api-keys-deprecated) + +Note: When creating API credentials, you must allow the Pod IP range of your Kubernetes cluster to use the credentials - otherwise, API requests from the Operator to Ops Manager will be rejected. +You can get the Pod IP range of your kubernetes cluster by executing the command: ```kubectl cluster-info dump | grep -m 1 cluster-cidr``` + +This is documented in greater detail in our [installation guide](https://docs.opsmanager.mongodb.com/current/tutorial/install-k8s-operator) + + +### Projects ### + +A `Project` object is a Kubernetes `ConfigMap` that points to an Ops Manager installation and a `Project`. This `ConfigMap` has the following structure: + +``` +$ cat my-project.yaml +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: my-project + namespace: mongodb +data: + projectName: myProjectName # this is an optional parameter + orgId: 5b890e0feacf0b76ff3e7183 # this is an optional parameter + baseUrl: https://my-ops-manager-or-cloud-manager-url +``` +> `projectName` is optional, and the value of `metadata.name` will be used if it is not defined. +> `orgId` is required. + +Apply this file to create the new `Project`: + + kubectl apply -f my-project.yaml + +### Credentials ### + +For a user to be able to create or update objects in this Ops Manager Project they need either a Public API Key or a +Programmatic API Key. These will be held by Kubernetes as a `Secret` object. You can create this Secret with the following command: + +``` bash +$ kubectl -n mongodb create secret generic my-credentials --from-literal="user=my-public-api-key" --from-literal="publicApiKey=my-private-api-key" +``` + +### Creating a MongoDB Resource ### + +A MongoDB resource in Kubernetes is a MongoDB. We are going to create a replica set to test that everything is working as expected. There is a MongoDB replica set yaml file in `samples/mongodb/minimal/replica-set.yaml`. + +If you have a Project with the name `my-project` and Credentials stored in a secret called `my-credentials`, then after applying this file everything should be running and a new Replica Set with 3 members should soon appear in Ops Manager UI. + + kubectl apply -f samples/mongodb/minimal/replica-set.yaml -n mongodb + +## MongoDBOpsManager Resource ## + +This section describes how to create the Ops Manager Custom Resource in Kubernetes. Note, that this requires all +the CRDs and the Operator application to be installed as described above. + +### Create Admin Credentials Secret ### + +Before creating the Ops Manager resource you need to prepare the information about the admin user which will be +created automatically in Ops Manager. You can use the following command to do it: + +```bash +$ kubectl create secret generic ops-manager-admin-secret --from-literal=Username="user.name@example.com" --from-literal=Password="Passw0rd." --from-literal=FirstName="User" --from-literal=LastName="Name" -n +``` + +Note, that the secret is needed only during the initialization of the Ops Manager object - you can remove it or +change the password using Ops Manager UI after the Ops Manager object is created. + +### Create MongoDBOpsManager Resource ### + +Use the file `samples/ops-manager/ops-manager.yaml`. Edit the fields and create the object in Kubernetes: + +```bash +$ kubectl apply -f samples/ops-manager/ops-manager.yaml -n +``` + +Note, that it can take up to 8 minutes to initialize the Application Database and start Ops Manager. + +## Accessing the Ops Manager UI using your web browser + +In order to access the Ops Manager UI from outside the Kubernetes cluster, you must enable `spec.externalConnectivity` in the Ops Manager resource definition. The easiest approach is by configuring the LoadBalancer service type. + +You will be able to fetch the URL to connect to Ops Manager UI from the `Service` object created by the Operator. + +## Removing the Operator, Databases and Ops Manager from your Kubernetes cluster ## + +As the Operator manages MongoDB and Ops Manager resources, if you want to remove them from your Kubernetes cluster, database instances and Ops Manager must be removed before removing the Operator. Removing the Operator first, or deleting the namespace will cause delays or stall the removal process of MongoDB objects, requiring manual intervention. + +Here is the correct order to completely remove the Operator and the services managed by it: + +* Remove all database clusters managed by the Operator +* Remove Ops Manager +* Remove the Operator +* Remove the CRDs + +## Contributing + +For PRs to be accepted, all contributors must sign our [CLA](https://www.mongodb.com/legal/contributor-agreement). + +Reviewers, please ensure that the CLA has been signed by referring to [the contributors tool](https://contributors.corp.mongodb.com/) (internal link). diff --git a/public/crds.yaml b/public/crds.yaml new file mode 100644 index 000000000..9d4f028d1 --- /dev/null +++ b/public/crds.yaml @@ -0,0 +1,2962 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.10.0 + creationTimestamp: null + name: mongodb.mongodb.com +spec: + group: mongodb.com + names: + kind: MongoDB + listKind: MongoDBList + plural: mongodb + shortNames: + - mdb + singular: mongodb + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Current state of the MongoDB deployment. + jsonPath: .status.phase + name: Phase + type: string + - description: Version of MongoDB server. + jsonPath: .status.version + name: Version + type: string + - description: The type of MongoDB deployment. One of 'ReplicaSet', 'ShardedCluster' + and 'Standalone'. + jsonPath: .spec.type + name: Type + type: string + - description: The time since the MongoDB resource was created. + jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + properties: + additionalMongodConfig: + description: 'AdditionalMongodConfig is additional configuration that + can be passed to each data-bearing mongod at runtime. Uses the same + structure as the mongod configuration file: https://docs.mongodb.com/manual/reference/configuration-options/' + type: object + x-kubernetes-preserve-unknown-fields: true + agent: + properties: + logLevel: + type: string + maxLogFileDurationHours: + type: integer + startupOptions: + additionalProperties: + type: string + type: object + type: object + backup: + description: Backup contains configuration options for configuring + backup for this MongoDB resource + properties: + assignmentLabels: + description: Assignment Labels set in the Ops Manager + items: + type: string + type: array + autoTerminateOnDeletion: + description: AutoTerminateOnDeletion indicates if the Operator + should stop and terminate the Backup before the cleanup, when + the MongoDB CR is deleted + type: boolean + encryption: + description: Encryption settings + properties: + kmip: + description: Kmip corresponds to the KMIP configuration assigned + to the Ops Manager Project's configuration. + properties: + client: + description: KMIP Client configuration + properties: + clientCertificatePrefix: + description: 'A prefix used to construct KMIP client + certificate (and corresponding password) Secret + names. The names are generated using the following + pattern: KMIP Client Certificate (TLS Secret): --kmip-client KMIP Client Certificate Password: + --kmip-client-password + The expected key inside is called "password".' + type: string + type: object + required: + - client + type: object + type: object + mode: + enum: + - enabled + - disabled + - terminated + type: string + snapshotSchedule: + properties: + clusterCheckpointIntervalMin: + enum: + - 15 + - 30 + - 60 + type: integer + dailySnapshotRetentionDays: + description: Number of days to retain daily snapshots. Setting + 0 will disable this rule. + maximum: 365 + minimum: 0 + type: integer + fullIncrementalDayOfWeek: + description: Day of the week when Ops Manager takes a full + snapshot. This ensures a recent complete backup. Ops Manager + sets the default value to SUNDAY. + enum: + - SUNDAY + - MONDAY + - TUESDAY + - WEDNESDAY + - THURSDAY + - FRIDAY + - SATURDAY + type: string + monthlySnapshotRetentionMonths: + description: Number of months to retain weekly snapshots. + Setting 0 will disable this rule. + maximum: 36 + minimum: 0 + type: integer + pointInTimeWindowHours: + description: Number of hours in the past for which a point-in-time + snapshot can be created. + enum: + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 15 + - 30 + - 60 + - 90 + - 120 + - 180 + - 360 + type: integer + referenceHourOfDay: + description: Hour of the day to schedule snapshots using a + 24-hour clock, in UTC. + maximum: 23 + minimum: 0 + type: integer + referenceMinuteOfHour: + description: Minute of the hour to schedule snapshots, in + UTC. + maximum: 59 + minimum: 0 + type: integer + snapshotIntervalHours: + description: Number of hours between snapshots. + enum: + - 6 + - 8 + - 12 + - 24 + type: integer + snapshotRetentionDays: + description: Number of days to keep recent snapshots. + maximum: 365 + minimum: 1 + type: integer + weeklySnapshotRetentionWeeks: + description: Number of weeks to retain weekly snapshots. Setting + 0 will disable this rule + maximum: 365 + minimum: 0 + type: integer + type: object + type: object + cloudManager: + properties: + configMapRef: + properties: + name: + type: string + type: object + type: object + clusterDomain: + format: hostname + type: string + configServerCount: + type: integer + configSrv: + properties: + additionalMongodConfig: + type: object + x-kubernetes-preserve-unknown-fields: true + agent: + properties: + logLevel: + type: string + maxLogFileDurationHours: + type: integer + startupOptions: + additionalProperties: + type: string + type: object + type: object + type: object + x-kubernetes-preserve-unknown-fields: true + configSrvPodSpec: + properties: + persistence: + properties: + multiple: + properties: + data: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + journal: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + logs: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + single: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + podTemplate: + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + connectivity: + properties: + replicaSetHorizons: + description: 'ReplicaSetHorizons holds list of maps of horizons + to be configured in each of MongoDB processes. Horizons map + horizon names to the node addresses for each process in the + replicaset, e.g.: [ { "internal": "my-rs-0.my-internal-domain.com:31843", + "external": "my-rs-0.my-external-domain.com:21467" }, { "internal": + "my-rs-1.my-internal-domain.com:31843", "external": "my-rs-1.my-external-domain.com:21467" + }, ... ] The key of each item in the map is an arbitrary, user-chosen + string that represents the name of the horizon. The value of + the item is the host and, optionally, the port that this mongod + node will be connected to from.' + items: + additionalProperties: + type: string + type: object + type: array + type: object + credentials: + description: Name of the Secret holding credentials information + type: string + exposedExternally: + description: 'DEPRECATED: use ExternalAccessConfiguration instead' + type: boolean + externalAccess: + description: ExternalAccessConfiguration provides external access + configuration. + properties: + externalDomain: + description: An external domain that is used for exposing MongoDB + to the outside world. + type: string + externalService: + description: Provides a way to override the default (NodePort) + Service + properties: + annotations: + additionalProperties: + type: string + description: A map of annotations that shall be added to the + externally available Service. + type: object + spec: + description: A wrapper for the Service spec object. + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + type: object + featureCompatibilityVersion: + type: string + logLevel: + enum: + - DEBUG + - INFO + - WARN + - ERROR + - FATAL + type: string + memberConfig: + description: MemberConfig + items: + properties: + priority: + type: string + tags: + additionalProperties: + type: string + type: object + votes: + type: integer + type: object + type: array + x-kubernetes-preserve-unknown-fields: true + members: + description: Amount of members for this MongoDB Replica Set + type: integer + mongodsPerShardCount: + type: integer + mongos: + properties: + additionalMongodConfig: + type: object + x-kubernetes-preserve-unknown-fields: true + agent: + properties: + logLevel: + type: string + maxLogFileDurationHours: + type: integer + startupOptions: + additionalProperties: + type: string + type: object + type: object + type: object + x-kubernetes-preserve-unknown-fields: true + mongosCount: + type: integer + mongosPodSpec: + properties: + persistence: + properties: + multiple: + properties: + data: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + journal: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + logs: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + single: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + podTemplate: + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + opsManager: + properties: + configMapRef: + properties: + name: + type: string + type: object + type: object + persistent: + type: boolean + podSpec: + properties: + persistence: + properties: + multiple: + properties: + data: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + journal: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + logs: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + single: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + podTemplate: + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + prometheus: + description: Prometheus configurations. + properties: + metricsPath: + description: Indicates path to the metrics endpoint. + pattern: ^\/[a-z0-9]+$ + type: string + passwordSecretRef: + description: Name of a Secret containing a HTTP Basic Auth Password. + properties: + key: + description: Key is the key in the secret storing this password. + Defaults to "password" + type: string + name: + description: Name is the name of the secret storing this user's + password + type: string + required: + - name + type: object + port: + description: Port where metrics endpoint will bind to. Defaults + to 9216. + type: integer + tlsSecretKeyRef: + description: Name of a Secret (type kubernetes.io/tls) holding + the certificates to use in the Prometheus endpoint. + properties: + key: + description: Key is the key in the secret storing this password. + Defaults to "password" + type: string + name: + description: Name is the name of the secret storing this user's + password + type: string + required: + - name + type: object + username: + description: HTTP Basic Auth Username for metrics endpoint. + type: string + required: + - passwordSecretRef + - username + type: object + security: + properties: + authentication: + description: Authentication holds various authentication related + settings that affect this MongoDB resource. + properties: + agents: + description: Agents contains authentication configuration + properties for the agents + properties: + automationLdapGroupDN: + type: string + automationPasswordSecretRef: + description: SecretKeySelector selects a key of a Secret. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, + uid?' + type: string + optional: + description: Specify whether the Secret or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + automationUserName: + type: string + clientCertificateSecretRef: + type: object + x-kubernetes-preserve-unknown-fields: true + mode: + description: Mode is the desired Authentication mode that + the agents will use + type: string + required: + - mode + type: object + enabled: + type: boolean + ignoreUnknownUsers: + description: IgnoreUnknownUsers maps to the inverse of auth.authoritativeSet + type: boolean + internalCluster: + type: string + ldap: + description: LDAP Configuration + properties: + authzQueryTemplate: + type: string + bindQueryPasswordSecretRef: + properties: + name: + type: string + required: + - name + type: object + bindQueryUser: + type: string + caConfigMapRef: + description: Allows to point at a ConfigMap/key with a + CA file to mount on the Pod + properties: + key: + description: The key to select. + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, + uid?' + type: string + optional: + description: Specify whether the ConfigMap or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + servers: + items: + type: string + type: array + timeoutMS: + type: integer + transportSecurity: + enum: + - tls + - none + type: string + userCacheInvalidationInterval: + type: integer + userToDNMapping: + type: string + validateLDAPServerConfig: + type: boolean + type: object + modes: + items: + type: string + type: array + requireClientTLSAuthentication: + description: Clients should present valid TLS certificates + type: boolean + required: + - enabled + type: object + certsSecretPrefix: + type: string + roles: + items: + properties: + authenticationRestrictions: + items: + properties: + clientSource: + items: + type: string + type: array + serverAddress: + items: + type: string + type: array + type: object + type: array + db: + type: string + privileges: + items: + properties: + actions: + items: + type: string + type: array + resource: + properties: + cluster: + type: boolean + collection: + type: string + db: + type: string + type: object + required: + - actions + - resource + type: object + type: array + role: + type: string + roles: + items: + properties: + db: + type: string + role: + type: string + required: + - db + - role + type: object + type: array + required: + - db + - role + type: object + type: array + tls: + properties: + additionalCertificateDomains: + items: + type: string + type: array + ca: + description: CA corresponds to a ConfigMap containing an entry + for the CA certificate (ca.pem) used to validate the certificates + created already. + type: string + enabled: + description: DEPRECATED please enable TLS by setting `security.certsSecretPrefix` + or `security.tls.secretRef.prefix`. Enables TLS for this + resource. This will make the operator try to mount a Secret + with a defined name (-cert). This is only + used when enabling TLS on a MongoDB resource, and not on + the AppDB, where TLS is configured by setting `secretRef.Name`. + type: boolean + type: object + type: object + service: + description: DEPRECATED please use `spec.statefulSet.spec.serviceName` + to provide a custom service name. this is an optional service, it + will get the name "-service" in case not provided + type: string + shard: + properties: + additionalMongodConfig: + type: object + x-kubernetes-preserve-unknown-fields: true + agent: + properties: + logLevel: + type: string + maxLogFileDurationHours: + type: integer + startupOptions: + additionalProperties: + type: string + type: object + type: object + type: object + x-kubernetes-preserve-unknown-fields: true + shardCount: + type: integer + shardPodSpec: + properties: + persistence: + properties: + multiple: + properties: + data: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + journal: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + logs: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + single: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + podTemplate: + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + shardSpecificPodSpec: + description: ShardSpecificPodSpec allows you to provide a Statefulset + override per shard. + items: + properties: + persistence: + properties: + multiple: + properties: + data: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + journal: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + logs: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + single: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + podTemplate: + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + type: array + statefulSet: + description: StatefulSetConfiguration provides the statefulset override + for each of the cluster's statefulset if "StatefulSetConfiguration" + is specified at cluster level under "clusterSpecList" that takes + precedence over the global one + properties: + spec: + type: object + x-kubernetes-preserve-unknown-fields: true + required: + - spec + type: object + type: + enum: + - Standalone + - ReplicaSet + - ShardedCluster + type: string + version: + pattern: ^[0-9]+.[0-9]+.[0-9]+(-.+)?$|^$ + type: string + required: + - credentials + - type + - version + type: object + x-kubernetes-preserve-unknown-fields: true + status: + properties: + backup: + properties: + statusName: + type: string + required: + - statusName + type: object + configServerCount: + type: integer + lastTransition: + type: string + link: + type: string + members: + type: integer + message: + type: string + mongodsPerShardCount: + type: integer + mongosCount: + type: integer + observedGeneration: + format: int64 + type: integer + phase: + type: string + resourcesNotReady: + items: + description: ResourceNotReady describes the dependent resource which + is not ready yet + properties: + errors: + items: + properties: + message: + type: string + reason: + type: string + type: object + type: array + kind: + description: ResourceKind specifies a kind of a Kubernetes resource. + Used in status of a Custom Resource + type: string + message: + type: string + name: + type: string + required: + - kind + - name + type: object + type: array + shardCount: + type: integer + version: + type: string + warnings: + items: + type: string + type: array + required: + - phase + - version + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.10.0 + creationTimestamp: null + name: mongodbmulticluster.mongodb.com +spec: + group: mongodb.com + names: + kind: MongoDBMultiCluster + listKind: MongoDBMultiClusterList + plural: mongodbmulticluster + shortNames: + - mdbmc + singular: mongodbmulticluster + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Current state of the MongoDB deployment. + jsonPath: .status.phase + name: Phase + type: string + - description: The time since the MongoDBMultiCluster resource was created. + jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + properties: + additionalMongodConfig: + description: 'AdditionalMongodConfig is additional configuration that + can be passed to each data-bearing mongod at runtime. Uses the same + structure as the mongod configuration file: https://docs.mongodb.com/manual/reference/configuration-options/' + type: object + x-kubernetes-preserve-unknown-fields: true + agent: + properties: + logLevel: + type: string + maxLogFileDurationHours: + type: integer + startupOptions: + additionalProperties: + type: string + type: object + type: object + backup: + description: Backup contains configuration options for configuring + backup for this MongoDB resource + properties: + assignmentLabels: + description: Assignment Labels set in the Ops Manager + items: + type: string + type: array + autoTerminateOnDeletion: + description: AutoTerminateOnDeletion indicates if the Operator + should stop and terminate the Backup before the cleanup, when + the MongoDB CR is deleted + type: boolean + encryption: + description: Encryption settings + properties: + kmip: + description: Kmip corresponds to the KMIP configuration assigned + to the Ops Manager Project's configuration. + properties: + client: + description: KMIP Client configuration + properties: + clientCertificatePrefix: + description: 'A prefix used to construct KMIP client + certificate (and corresponding password) Secret + names. The names are generated using the following + pattern: KMIP Client Certificate (TLS Secret): --kmip-client KMIP Client Certificate Password: + --kmip-client-password + The expected key inside is called "password".' + type: string + type: object + required: + - client + type: object + type: object + mode: + enum: + - enabled + - disabled + - terminated + type: string + snapshotSchedule: + properties: + clusterCheckpointIntervalMin: + enum: + - 15 + - 30 + - 60 + type: integer + dailySnapshotRetentionDays: + description: Number of days to retain daily snapshots. Setting + 0 will disable this rule. + maximum: 365 + minimum: 0 + type: integer + fullIncrementalDayOfWeek: + description: Day of the week when Ops Manager takes a full + snapshot. This ensures a recent complete backup. Ops Manager + sets the default value to SUNDAY. + enum: + - SUNDAY + - MONDAY + - TUESDAY + - WEDNESDAY + - THURSDAY + - FRIDAY + - SATURDAY + type: string + monthlySnapshotRetentionMonths: + description: Number of months to retain weekly snapshots. + Setting 0 will disable this rule. + maximum: 36 + minimum: 0 + type: integer + pointInTimeWindowHours: + description: Number of hours in the past for which a point-in-time + snapshot can be created. + enum: + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 15 + - 30 + - 60 + - 90 + - 120 + - 180 + - 360 + type: integer + referenceHourOfDay: + description: Hour of the day to schedule snapshots using a + 24-hour clock, in UTC. + maximum: 23 + minimum: 0 + type: integer + referenceMinuteOfHour: + description: Minute of the hour to schedule snapshots, in + UTC. + maximum: 59 + minimum: 0 + type: integer + snapshotIntervalHours: + description: Number of hours between snapshots. + enum: + - 6 + - 8 + - 12 + - 24 + type: integer + snapshotRetentionDays: + description: Number of days to keep recent snapshots. + maximum: 365 + minimum: 1 + type: integer + weeklySnapshotRetentionWeeks: + description: Number of weeks to retain weekly snapshots. Setting + 0 will disable this rule + maximum: 365 + minimum: 0 + type: integer + type: object + type: object + cloudManager: + properties: + configMapRef: + properties: + name: + type: string + type: object + type: object + clusterDomain: + format: hostname + type: string + clusterSpecList: + items: + description: ClusterSpecItem is the mongodb multi-cluster spec that + is specific to a particular Kubernetes cluster, this maps to the + statefulset created in each cluster + properties: + clusterName: + description: ClusterName is name of the cluster where the MongoDB + Statefulset will be scheduled, the name should have a one + on one mapping with the service-account created in the central + cluster to talk to the workload clusters. + type: string + exposedExternally: + description: 'DEPRECATED: use ExternalAccessConfiguration instead' + type: boolean + externalAccess: + description: ExternalAccessConfiguration provides external access + configuration for Multi-Cluster. + properties: + externalDomain: + description: An external domain that is used for exposing + MongoDB to the outside world. + type: string + externalService: + description: Provides a way to override the default (NodePort) + Service + properties: + annotations: + additionalProperties: + type: string + description: A map of annotations that shall be added + to the externally available Service. + type: object + spec: + description: A wrapper for the Service spec object. + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + type: object + memberConfig: + description: MemberConfig + items: + properties: + priority: + type: string + tags: + additionalProperties: + type: string + type: object + votes: + type: integer + type: object + type: array + x-kubernetes-preserve-unknown-fields: true + members: + description: Amount of members for this MongoDB Replica Set + type: integer + service: + description: this is an optional service, it will get the name + "-service" in case not provided + type: string + statefulSet: + description: StatefulSetConfiguration holds the optional custom + StatefulSet that should be merged into the operator created + one. + properties: + spec: + type: object + x-kubernetes-preserve-unknown-fields: true + required: + - spec + type: object + required: + - members + type: object + type: array + connectivity: + properties: + replicaSetHorizons: + description: 'ReplicaSetHorizons holds list of maps of horizons + to be configured in each of MongoDB processes. Horizons map + horizon names to the node addresses for each process in the + replicaset, e.g.: [ { "internal": "my-rs-0.my-internal-domain.com:31843", + "external": "my-rs-0.my-external-domain.com:21467" }, { "internal": + "my-rs-1.my-internal-domain.com:31843", "external": "my-rs-1.my-external-domain.com:21467" + }, ... ] The key of each item in the map is an arbitrary, user-chosen + string that represents the name of the horizon. The value of + the item is the host and, optionally, the port that this mongod + node will be connected to from.' + items: + additionalProperties: + type: string + type: object + type: array + type: object + credentials: + description: Name of the Secret holding credentials information + type: string + duplicateServiceObjects: + description: 'In few service mesh options for ex: Istio, by default + we would need to duplicate the service objects created per pod in + all the clusters to enable DNS resolution. Users can however configure + their ServiceMesh with DNS proxy(https://istio.io/latest/docs/ops/configuration/traffic-management/dns-proxy/) + enabled in which case the operator doesn''t need to create the service + objects per cluster. This options tells the operator whether it + should create the service objects in all the clusters or not. By + default, if not specified the operator would create the duplicate + svc objects.' + type: boolean + exposedExternally: + description: 'DEPRECATED: use ExternalAccessConfiguration instead' + type: boolean + externalAccess: + description: ExternalAccessConfiguration provides external access + configuration. + properties: + externalDomain: + description: An external domain that is used for exposing MongoDB + to the outside world. + type: string + externalService: + description: Provides a way to override the default (NodePort) + Service + properties: + annotations: + additionalProperties: + type: string + description: A map of annotations that shall be added to the + externally available Service. + type: object + spec: + description: A wrapper for the Service spec object. + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + type: object + featureCompatibilityVersion: + type: string + logLevel: + enum: + - DEBUG + - INFO + - WARN + - ERROR + - FATAL + type: string + opsManager: + properties: + configMapRef: + properties: + name: + type: string + type: object + type: object + persistent: + type: boolean + prometheus: + description: Prometheus configurations. + properties: + metricsPath: + description: Indicates path to the metrics endpoint. + pattern: ^\/[a-z0-9]+$ + type: string + passwordSecretRef: + description: Name of a Secret containing a HTTP Basic Auth Password. + properties: + key: + description: Key is the key in the secret storing this password. + Defaults to "password" + type: string + name: + description: Name is the name of the secret storing this user's + password + type: string + required: + - name + type: object + port: + description: Port where metrics endpoint will bind to. Defaults + to 9216. + type: integer + tlsSecretKeyRef: + description: Name of a Secret (type kubernetes.io/tls) holding + the certificates to use in the Prometheus endpoint. + properties: + key: + description: Key is the key in the secret storing this password. + Defaults to "password" + type: string + name: + description: Name is the name of the secret storing this user's + password + type: string + required: + - name + type: object + username: + description: HTTP Basic Auth Username for metrics endpoint. + type: string + required: + - passwordSecretRef + - username + type: object + security: + properties: + authentication: + description: Authentication holds various authentication related + settings that affect this MongoDB resource. + properties: + agents: + description: Agents contains authentication configuration + properties for the agents + properties: + automationLdapGroupDN: + type: string + automationPasswordSecretRef: + description: SecretKeySelector selects a key of a Secret. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, + uid?' + type: string + optional: + description: Specify whether the Secret or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + automationUserName: + type: string + clientCertificateSecretRef: + type: object + x-kubernetes-preserve-unknown-fields: true + mode: + description: Mode is the desired Authentication mode that + the agents will use + type: string + required: + - mode + type: object + enabled: + type: boolean + ignoreUnknownUsers: + description: IgnoreUnknownUsers maps to the inverse of auth.authoritativeSet + type: boolean + internalCluster: + type: string + ldap: + description: LDAP Configuration + properties: + authzQueryTemplate: + type: string + bindQueryPasswordSecretRef: + properties: + name: + type: string + required: + - name + type: object + bindQueryUser: + type: string + caConfigMapRef: + description: Allows to point at a ConfigMap/key with a + CA file to mount on the Pod + properties: + key: + description: The key to select. + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, + uid?' + type: string + optional: + description: Specify whether the ConfigMap or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + servers: + items: + type: string + type: array + timeoutMS: + type: integer + transportSecurity: + enum: + - tls + - none + type: string + userCacheInvalidationInterval: + type: integer + userToDNMapping: + type: string + validateLDAPServerConfig: + type: boolean + type: object + modes: + items: + type: string + type: array + requireClientTLSAuthentication: + description: Clients should present valid TLS certificates + type: boolean + required: + - enabled + type: object + certsSecretPrefix: + type: string + roles: + items: + properties: + authenticationRestrictions: + items: + properties: + clientSource: + items: + type: string + type: array + serverAddress: + items: + type: string + type: array + type: object + type: array + db: + type: string + privileges: + items: + properties: + actions: + items: + type: string + type: array + resource: + properties: + cluster: + type: boolean + collection: + type: string + db: + type: string + type: object + required: + - actions + - resource + type: object + type: array + role: + type: string + roles: + items: + properties: + db: + type: string + role: + type: string + required: + - db + - role + type: object + type: array + required: + - db + - role + type: object + type: array + tls: + properties: + additionalCertificateDomains: + items: + type: string + type: array + ca: + description: CA corresponds to a ConfigMap containing an entry + for the CA certificate (ca.pem) used to validate the certificates + created already. + type: string + enabled: + description: DEPRECATED please enable TLS by setting `security.certsSecretPrefix` + or `security.tls.secretRef.prefix`. Enables TLS for this + resource. This will make the operator try to mount a Secret + with a defined name (-cert). This is only + used when enabling TLS on a MongoDB resource, and not on + the AppDB, where TLS is configured by setting `secretRef.Name`. + type: boolean + type: object + type: object + statefulSet: + description: StatefulSetConfiguration provides the statefulset override + for each of the cluster's statefulset if "StatefulSetConfiguration" + is specified at cluster level under "clusterSpecList" that takes + precedence over the global one + properties: + spec: + type: object + x-kubernetes-preserve-unknown-fields: true + required: + - spec + type: object + type: + enum: + - Standalone + - ReplicaSet + - ShardedCluster + type: string + version: + pattern: ^[0-9]+.[0-9]+.[0-9]+(-.+)?$|^$ + type: string + required: + - credentials + - type + - version + type: object + x-kubernetes-preserve-unknown-fields: true + status: + properties: + backup: + properties: + statusName: + type: string + required: + - statusName + type: object + clusterStatusList: + description: ClusterStatusList holds a list of clusterStatuses corresponding + to each cluster + properties: + clusterStatuses: + items: + description: ClusterStatusItem is the mongodb multi-cluster + spec that is specific to a particular Kubernetes cluster, + this maps to the statefulset created in each cluster + properties: + clusterName: + description: ClusterName is name of the cluster where the + MongoDB Statefulset will be scheduled, the name should + have a one on one mapping with the service-account created + in the central cluster to talk to the workload clusters. + type: string + lastTransition: + type: string + members: + type: integer + message: + type: string + observedGeneration: + format: int64 + type: integer + phase: + type: string + resourcesNotReady: + items: + description: ResourceNotReady describes the dependent + resource which is not ready yet + properties: + errors: + items: + properties: + message: + type: string + reason: + type: string + type: object + type: array + kind: + description: ResourceKind specifies a kind of a Kubernetes + resource. Used in status of a Custom Resource + type: string + message: + type: string + name: + type: string + required: + - kind + - name + type: object + type: array + warnings: + items: + type: string + type: array + required: + - phase + type: object + type: array + type: object + lastTransition: + type: string + link: + type: string + message: + type: string + observedGeneration: + format: int64 + type: integer + phase: + type: string + resourcesNotReady: + items: + description: ResourceNotReady describes the dependent resource which + is not ready yet + properties: + errors: + items: + properties: + message: + type: string + reason: + type: string + type: object + type: array + kind: + description: ResourceKind specifies a kind of a Kubernetes resource. + Used in status of a Custom Resource + type: string + message: + type: string + name: + type: string + required: + - kind + - name + type: object + type: array + version: + type: string + warnings: + items: + type: string + type: array + required: + - phase + - version + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.10.0 + creationTimestamp: null + name: mongodbusers.mongodb.com +spec: + group: mongodb.com + names: + kind: MongoDBUser + listKind: MongoDBUserList + plural: mongodbusers + shortNames: + - mdbu + singular: mongodbuser + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: The current state of the MongoDB User. + jsonPath: .status.phase + name: Phase + type: string + - description: The time since the MongoDB User resource was created. + jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + properties: + connectionStringSecretName: + type: string + db: + type: string + mongodbResourceRef: + properties: + name: + type: string + namespace: + type: string + required: + - name + type: object + passwordSecretKeyRef: + description: 'SecretKeyRef is a reference to a value in a given secret + in the same namespace. Based on: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.15/#secretkeyselector-v1-core' + properties: + key: + type: string + name: + type: string + required: + - name + type: object + roles: + items: + properties: + db: + type: string + name: + type: string + required: + - db + - name + type: object + type: array + username: + type: string + required: + - db + - username + type: object + status: + properties: + db: + type: string + lastTransition: + type: string + message: + type: string + observedGeneration: + format: int64 + type: integer + phase: + type: string + project: + type: string + resourcesNotReady: + items: + description: ResourceNotReady describes the dependent resource which + is not ready yet + properties: + errors: + items: + properties: + message: + type: string + reason: + type: string + type: object + type: array + kind: + description: ResourceKind specifies a kind of a Kubernetes resource. + Used in status of a Custom Resource + type: string + message: + type: string + name: + type: string + required: + - kind + - name + type: object + type: array + roles: + items: + properties: + db: + type: string + name: + type: string + required: + - db + - name + type: object + type: array + username: + type: string + warnings: + items: + type: string + type: array + required: + - db + - phase + - project + - username + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.10.0 + creationTimestamp: null + name: opsmanagers.mongodb.com +spec: + group: mongodb.com + names: + kind: MongoDBOpsManager + listKind: MongoDBOpsManagerList + plural: opsmanagers + shortNames: + - om + singular: opsmanager + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: The number of replicas of MongoDBOpsManager. + jsonPath: .spec.replicas + name: Replicas + type: integer + - description: The version of MongoDBOpsManager. + jsonPath: .spec.version + name: Version + type: string + - description: The current state of the MongoDBOpsManager. + jsonPath: .status.opsManager.phase + name: State (OpsManager) + type: string + - description: The current state of the MongoDBOpsManager Application Database. + jsonPath: .status.applicationDatabase.phase + name: State (AppDB) + type: string + - description: The current state of the MongoDBOpsManager Backup Daemon. + jsonPath: .status.backup.phase + name: State (Backup) + type: string + - description: The time since the MongoDBOpsManager resource was created. + jsonPath: .metadata.creationTimestamp + name: Age + type: date + - description: Warnings. + jsonPath: .status.warnings + name: Warnings + type: string + name: v1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + properties: + adminCredentials: + description: 'AdminSecret is the secret for the first admin user to + create has the fields: "Username", "Password", "FirstName", "LastName"' + type: string + applicationDatabase: + properties: + additionalMongodConfig: + description: 'AdditionalMongodConfig is additional configuration + that can be passed to each data-bearing mongod at runtime. Uses + the same structure as the mongod configuration file: https://docs.mongodb.com/manual/reference/configuration-options/' + type: object + x-kubernetes-preserve-unknown-fields: true + agent: + description: specify startup flags for the AutomationAgent and + MonitoringAgent + properties: + logLevel: + type: string + maxLogFileDurationHours: + type: integer + startupOptions: + additionalProperties: + type: string + type: object + type: object + automationConfig: + description: AutomationConfigOverride holds any fields that will + be merged on top of the Automation Config that the operator + creates for the AppDB. Currently only the process.disabled field + is recognized. + properties: + processes: + items: + description: OverrideProcess contains fields that we can + override on the AutomationConfig processes. + properties: + disabled: + type: boolean + name: + type: string + required: + - disabled + - name + type: object + type: array + required: + - processes + type: object + cloudManager: + properties: + configMapRef: + properties: + name: + type: string + type: object + type: object + clusterDomain: + type: string + connectivity: + properties: + replicaSetHorizons: + description: 'ReplicaSetHorizons holds list of maps of horizons + to be configured in each of MongoDB processes. Horizons + map horizon names to the node addresses for each process + in the replicaset, e.g.: [ { "internal": "my-rs-0.my-internal-domain.com:31843", + "external": "my-rs-0.my-external-domain.com:21467" }, { + "internal": "my-rs-1.my-internal-domain.com:31843", "external": + "my-rs-1.my-external-domain.com:21467" }, ... ] The key + of each item in the map is an arbitrary, user-chosen string + that represents the name of the horizon. The value of the + item is the host and, optionally, the port that this mongod + node will be connected to from.' + items: + additionalProperties: + type: string + type: object + type: array + type: object + credentials: + description: Name of the Secret holding credentials information + type: string + featureCompatibilityVersion: + type: string + logLevel: + enum: + - DEBUG + - INFO + - WARN + - ERROR + - FATAL + type: string + memberConfig: + description: MemberConfig + items: + properties: + priority: + type: string + tags: + additionalProperties: + type: string + type: object + votes: + type: integer + type: object + type: array + members: + description: Amount of members for this MongoDB Replica Set + maximum: 50 + minimum: 3 + type: integer + monitoringAgent: + description: specify startup flags for just the MonitoringAgent. + These take precedence over the flags set in AutomationAgent + properties: + logLevel: + type: string + maxLogFileDurationHours: + type: integer + startupOptions: + additionalProperties: + type: string + type: object + type: object + opsManager: + properties: + configMapRef: + properties: + name: + type: string + type: object + type: object + passwordSecretKeyRef: + description: PasswordSecretKeyRef contains a reference to the + secret which contains the password for the mongodb-ops-manager + SCRAM-SHA user + properties: + key: + type: string + name: + type: string + required: + - name + type: object + podSpec: + properties: + persistence: + properties: + multiple: + properties: + data: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + journal: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + logs: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + single: + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + type: object + podTemplate: + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + prometheus: + description: Enables Prometheus integration on the AppDB. + properties: + metricsPath: + description: Indicates path to the metrics endpoint. + pattern: ^\/[a-z0-9]+$ + type: string + passwordSecretRef: + description: Name of a Secret containing a HTTP Basic Auth + Password. + properties: + key: + description: Key is the key in the secret storing this + password. Defaults to "password" + type: string + name: + description: Name is the name of the secret storing this + user's password + type: string + required: + - name + type: object + port: + description: Port where metrics endpoint will bind to. Defaults + to 9216. + type: integer + tlsSecretKeyRef: + description: Name of a Secret (type kubernetes.io/tls) holding + the certificates to use in the Prometheus endpoint. + properties: + key: + description: Key is the key in the secret storing this + password. Defaults to "password" + type: string + name: + description: Name is the name of the secret storing this + user's password + type: string + required: + - name + type: object + username: + description: HTTP Basic Auth Username for metrics endpoint. + type: string + required: + - passwordSecretRef + - username + type: object + security: + properties: + authentication: + description: Authentication holds various authentication related + settings that affect this MongoDB resource. + properties: + agents: + description: Agents contains authentication configuration + properties for the agents + properties: + automationLdapGroupDN: + type: string + automationPasswordSecretRef: + description: SecretKeySelector selects a key of a + Secret. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + description: 'Name of the referent. More info: + https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, + uid?' + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + automationUserName: + type: string + clientCertificateSecretRef: + type: object + x-kubernetes-preserve-unknown-fields: true + mode: + description: Mode is the desired Authentication mode + that the agents will use + type: string + required: + - mode + type: object + enabled: + type: boolean + ignoreUnknownUsers: + description: IgnoreUnknownUsers maps to the inverse of + auth.authoritativeSet + type: boolean + internalCluster: + type: string + ldap: + description: LDAP Configuration + properties: + authzQueryTemplate: + type: string + bindQueryPasswordSecretRef: + properties: + name: + type: string + required: + - name + type: object + bindQueryUser: + type: string + caConfigMapRef: + description: Allows to point at a ConfigMap/key with + a CA file to mount on the Pod + properties: + key: + description: The key to select. + type: string + name: + description: 'Name of the referent. More info: + https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, + uid?' + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + servers: + items: + type: string + type: array + timeoutMS: + type: integer + transportSecurity: + enum: + - tls + - none + type: string + userCacheInvalidationInterval: + type: integer + userToDNMapping: + type: string + validateLDAPServerConfig: + type: boolean + type: object + modes: + items: + type: string + type: array + requireClientTLSAuthentication: + description: Clients should present valid TLS certificates + type: boolean + required: + - enabled + type: object + certsSecretPrefix: + type: string + roles: + items: + properties: + authenticationRestrictions: + items: + properties: + clientSource: + items: + type: string + type: array + serverAddress: + items: + type: string + type: array + type: object + type: array + db: + type: string + privileges: + items: + properties: + actions: + items: + type: string + type: array + resource: + properties: + cluster: + type: boolean + collection: + type: string + db: + type: string + type: object + required: + - actions + - resource + type: object + type: array + role: + type: string + roles: + items: + properties: + db: + type: string + role: + type: string + required: + - db + - role + type: object + type: array + required: + - db + - role + type: object + type: array + tls: + properties: + additionalCertificateDomains: + items: + type: string + type: array + ca: + description: CA corresponds to a ConfigMap containing + an entry for the CA certificate (ca.pem) used to validate + the certificates created already. + type: string + enabled: + description: DEPRECATED please enable TLS by setting `security.certsSecretPrefix` + or `security.tls.secretRef.prefix`. Enables TLS for + this resource. This will make the operator try to mount + a Secret with a defined name (-cert). + This is only used when enabling TLS on a MongoDB resource, + and not on the AppDB, where TLS is configured by setting + `secretRef.Name`. + type: boolean + type: object + type: object + service: + description: this is an optional service, it will get the name + "-service" in case not provided + type: string + type: + enum: + - Standalone + - ReplicaSet + - ShardedCluster + type: string + version: + pattern: ^[0-9]+.[0-9]+.[0-9]+(-.+)?$|^$ + type: string + required: + - version + type: object + backup: + description: Backup + properties: + assignmentLabels: + description: Assignment Labels set in the Ops Manager + items: + type: string + type: array + blockStores: + items: + description: DataStoreConfig is the description of the config + used to reference to database. Reused by Oplog and Block stores + Optionally references the user if the Mongodb is configured + with authentication + properties: + assignmentLabels: + description: Assignment Labels set in the Ops Manager + items: + type: string + type: array + mongodbResourceRef: + properties: + name: + type: string + namespace: + type: string + required: + - name + type: object + mongodbUserRef: + properties: + name: + type: string + required: + - name + type: object + name: + type: string + required: + - mongodbResourceRef + - name + type: object + type: array + enabled: + description: Enabled indicates if Backups will be enabled for + this Ops Manager. + type: boolean + encryption: + description: Encryption settings + properties: + kmip: + description: Kmip corresponds to the KMIP configuration assigned + to the Ops Manager Project's configuration. + properties: + server: + description: KMIP Server configuration + properties: + ca: + description: CA corresponds to a ConfigMap containing + an entry for the CA certificate (ca.pem) used for + KMIP authentication + type: string + url: + description: 'KMIP Server url in the following format: + hostname:port Valid examples are: 10.10.10.3:5696 + my-kmip-server.mycorp.com:5696 kmip-svc.svc.cluster.local:5696' + pattern: '[^\:]+:[0-9]{0,5}' + type: string + required: + - ca + - url + type: object + required: + - server + type: object + type: object + externalServiceEnabled: + type: boolean + fileSystemStores: + items: + properties: + name: + type: string + required: + - name + type: object + type: array + headDB: + description: HeadDB specifies configuration options for the HeadDB + properties: + labelSelector: + type: object + x-kubernetes-preserve-unknown-fields: true + storage: + type: string + storageClass: + type: string + type: object + jvmParameters: + items: + type: string + type: array + members: + description: Members indicate the number of backup daemon pods + to create. + minimum: 1 + type: integer + opLogStores: + description: OplogStoreConfigs describes the list of oplog store + configs used for backup + items: + description: DataStoreConfig is the description of the config + used to reference to database. Reused by Oplog and Block stores + Optionally references the user if the Mongodb is configured + with authentication + properties: + assignmentLabels: + description: Assignment Labels set in the Ops Manager + items: + type: string + type: array + mongodbResourceRef: + properties: + name: + type: string + namespace: + type: string + required: + - name + type: object + mongodbUserRef: + properties: + name: + type: string + required: + - name + type: object + name: + type: string + required: + - mongodbResourceRef + - name + type: object + type: array + queryableBackupSecretRef: + description: QueryableBackupSecretRef references the secret which + contains the pem file which is used for queryable backup. This + will be mounted into the Ops Manager pod. + properties: + name: + type: string + required: + - name + type: object + s3OpLogStores: + description: S3OplogStoreConfigs describes the list of s3 oplog + store configs used for backup. + items: + properties: + assignmentLabels: + description: Assignment Labels set in the Ops Manager + items: + type: string + type: array + customCertificate: + description: Set this to "true" when you have custom certificates + for your S3 buckets + type: boolean + irsaEnabled: + description: 'This is only set to "true" when user is running + in EKS and is using AWS IRSA to configure S3 snapshot + store. For more details refer this: https://aws.amazon.com/blogs/opensource/introducing-fine-grained-iam-roles-service-accounts/' + type: boolean + mongodbResourceRef: + properties: + name: + type: string + namespace: + type: string + required: + - name + type: object + mongodbUserRef: + properties: + name: + type: string + required: + - name + type: object + name: + type: string + pathStyleAccessEnabled: + type: boolean + s3BucketEndpoint: + type: string + s3BucketName: + type: string + s3RegionOverride: + type: string + s3SecretRef: + properties: + name: + type: string + required: + - name + type: object + required: + - name + - pathStyleAccessEnabled + - s3BucketEndpoint + - s3BucketName + - s3SecretRef + type: object + type: array + s3Stores: + items: + properties: + assignmentLabels: + description: Assignment Labels set in the Ops Manager + items: + type: string + type: array + customCertificate: + description: Set this to "true" when you have custom certificates + for your S3 buckets + type: boolean + irsaEnabled: + description: 'This is only set to "true" when user is running + in EKS and is using AWS IRSA to configure S3 snapshot + store. For more details refer this: https://aws.amazon.com/blogs/opensource/introducing-fine-grained-iam-roles-service-accounts/' + type: boolean + mongodbResourceRef: + properties: + name: + type: string + namespace: + type: string + required: + - name + type: object + mongodbUserRef: + properties: + name: + type: string + required: + - name + type: object + name: + type: string + pathStyleAccessEnabled: + type: boolean + s3BucketEndpoint: + type: string + s3BucketName: + type: string + s3RegionOverride: + type: string + s3SecretRef: + properties: + name: + type: string + required: + - name + type: object + required: + - name + - pathStyleAccessEnabled + - s3BucketEndpoint + - s3BucketName + - s3SecretRef + type: object + type: array + statefulSet: + description: StatefulSetConfiguration holds the optional custom + StatefulSet that should be merged into the operator created + one. + properties: + spec: + type: object + x-kubernetes-preserve-unknown-fields: true + required: + - spec + type: object + required: + - enabled + type: object + clusterDomain: + format: hostname + type: string + clusterName: + description: 'Deprecated: This has been replaced by the ClusterDomain + which should be used instead' + format: hostname + type: string + configuration: + additionalProperties: + type: string + description: The configuration properties passed to Ops Manager/Backup + Daemon + type: object + externalConnectivity: + description: MongoDBOpsManagerExternalConnectivity if sets allows + for the creation of a Service for accessing this Ops Manager resource + from outside the Kubernetes cluster. + properties: + annotations: + additionalProperties: + type: string + description: Annotations is a list of annotations to be directly + passed to the Service object. + type: object + externalTrafficPolicy: + description: ExternalTrafficPolicy mechanism to preserve the client + source IP. Only supported on GCE and Google Kubernetes Engine. + enum: + - Cluster + - Local + type: string + loadBalancerIP: + description: LoadBalancerIP IP that will be assigned to this LoadBalancer. + type: string + port: + description: Port in which this `Service` will listen to, this + applies to `NodePort`. + format: int32 + type: integer + type: + description: Type of the `Service` to be created. + enum: + - LoadBalancer + - NodePort + type: string + required: + - type + type: object + jvmParameters: + description: Custom JVM parameters passed to the Ops Manager JVM + items: + type: string + type: array + replicas: + minimum: 1 + type: integer + security: + description: Configure HTTPS. + properties: + certsSecretPrefix: + type: string + tls: + properties: + ca: + type: string + secretRef: + properties: + name: + type: string + required: + - name + type: object + type: object + type: object + statefulSet: + description: Configure custom StatefulSet configuration + properties: + spec: + type: object + x-kubernetes-preserve-unknown-fields: true + required: + - spec + type: object + version: + type: string + required: + - applicationDatabase + - version + type: object + status: + properties: + applicationDatabase: + properties: + backup: + properties: + statusName: + type: string + required: + - statusName + type: object + configServerCount: + type: integer + lastTransition: + type: string + link: + type: string + members: + type: integer + message: + type: string + mongodsPerShardCount: + type: integer + mongosCount: + type: integer + observedGeneration: + format: int64 + type: integer + phase: + type: string + resourcesNotReady: + items: + description: ResourceNotReady describes the dependent resource + which is not ready yet + properties: + errors: + items: + properties: + message: + type: string + reason: + type: string + type: object + type: array + kind: + description: ResourceKind specifies a kind of a Kubernetes + resource. Used in status of a Custom Resource + type: string + message: + type: string + name: + type: string + required: + - kind + - name + type: object + type: array + shardCount: + type: integer + version: + type: string + warnings: + items: + type: string + type: array + required: + - phase + - version + type: object + backup: + properties: + lastTransition: + type: string + message: + type: string + observedGeneration: + format: int64 + type: integer + phase: + type: string + resourcesNotReady: + items: + description: ResourceNotReady describes the dependent resource + which is not ready yet + properties: + errors: + items: + properties: + message: + type: string + reason: + type: string + type: object + type: array + kind: + description: ResourceKind specifies a kind of a Kubernetes + resource. Used in status of a Custom Resource + type: string + message: + type: string + name: + type: string + required: + - kind + - name + type: object + type: array + version: + type: string + warnings: + items: + type: string + type: array + required: + - phase + type: object + opsManager: + properties: + lastTransition: + type: string + message: + type: string + observedGeneration: + format: int64 + type: integer + phase: + type: string + replicas: + type: integer + resourcesNotReady: + items: + description: ResourceNotReady describes the dependent resource + which is not ready yet + properties: + errors: + items: + properties: + message: + type: string + reason: + type: string + type: object + type: array + kind: + description: ResourceKind specifies a kind of a Kubernetes + resource. Used in status of a Custom Resource + type: string + message: + type: string + name: + type: string + required: + - kind + - name + type: object + type: array + url: + type: string + version: + type: string + warnings: + items: + type: string + type: array + required: + - phase + type: object + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/public/dockerfiles/mongodb-agent/10.29.0.6830-1/ubi/Dockerfile b/public/dockerfiles/mongodb-agent/10.29.0.6830-1/ubi/Dockerfile new file mode 100644 index 000000000..6fa70680e --- /dev/null +++ b/public/dockerfiles/mongodb-agent/10.29.0.6830-1/ubi/Dockerfile @@ -0,0 +1,33 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi7/ubi + +RUN yum install -y --disableplugin=subscription-manager -q curl \ + hostname nss_wrapper --exclude perl-IO-Socket-SSL procps \ + && yum upgrade -y -q \ + && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/10.29.0.6830-1/ubuntu/Dockerfile b/public/dockerfiles/mongodb-agent/10.29.0.6830-1/ubuntu/Dockerfile new file mode 100644 index 000000000..c00a34ae7 --- /dev/null +++ b/public/dockerfiles/mongodb-agent/10.29.0.6830-1/ubuntu/Dockerfile @@ -0,0 +1,36 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:16.04 + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + curl \ + libnss-wrapper \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/11.0.1.6929-1/ubi/Dockerfile b/public/dockerfiles/mongodb-agent/11.0.1.6929-1/ubi/Dockerfile new file mode 100644 index 000000000..1ae2cf2b3 --- /dev/null +++ b/public/dockerfiles/mongodb-agent/11.0.1.6929-1/ubi/Dockerfile @@ -0,0 +1,44 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi7/ubi + +ARG agent_version + +LABEL name="MongoDB Agent" \ + version="${agent_version}" \ + summary="MongoDB Agent" \ + description="MongoDB Agent" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +RUN yum install -y --disableplugin=subscription-manager -q curl \ + hostname nss_wrapper --exclude perl-IO-Socket-SSL procps \ + && yum upgrade -y -q \ + && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent +COPY --from=base /data/LICENSE /licenses/LICENSE + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/11.0.1.6929-1/ubuntu/Dockerfile b/public/dockerfiles/mongodb-agent/11.0.1.6929-1/ubuntu/Dockerfile new file mode 100644 index 000000000..f96eaa869 --- /dev/null +++ b/public/dockerfiles/mongodb-agent/11.0.1.6929-1/ubuntu/Dockerfile @@ -0,0 +1,47 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + +ARG agent_version + +LABEL name="MongoDB Agent" \ + version="${agent_version}" \ + summary="MongoDB Agent" \ + description="MongoDB Agent" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + curl \ + libnss-wrapper \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent +COPY --from=base /data/LICENSE /licenses/LICENSE + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/11.0.11.7036-1/ubi/Dockerfile b/public/dockerfiles/mongodb-agent/11.0.11.7036-1/ubi/Dockerfile new file mode 100644 index 000000000..1f87881f4 --- /dev/null +++ b/public/dockerfiles/mongodb-agent/11.0.11.7036-1/ubi/Dockerfile @@ -0,0 +1,46 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG agent_version + +LABEL name="MongoDB Agent" \ + version="${agent_version}" \ + summary="MongoDB Agent" \ + description="MongoDB Agent" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +RUN microdnf install -y --disableplugin=subscription-manager curl \ + hostname nss_wrapper tar gzip procps\ + && microdnf upgrade -y \ + && rm -rf /var/lib/apt/lists/* + +RUN microdnf remove perl-IO-Socket-SSL +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent +COPY --from=base /data/LICENSE /licenses/LICENSE + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/11.0.11.7036-1/ubuntu/Dockerfile b/public/dockerfiles/mongodb-agent/11.0.11.7036-1/ubuntu/Dockerfile new file mode 100644 index 000000000..f96eaa869 --- /dev/null +++ b/public/dockerfiles/mongodb-agent/11.0.11.7036-1/ubuntu/Dockerfile @@ -0,0 +1,47 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + +ARG agent_version + +LABEL name="MongoDB Agent" \ + version="${agent_version}" \ + summary="MongoDB Agent" \ + description="MongoDB Agent" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + curl \ + libnss-wrapper \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent +COPY --from=base /data/LICENSE /licenses/LICENSE + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/11.0.12.7051-1/ubi/Dockerfile b/public/dockerfiles/mongodb-agent/11.0.12.7051-1/ubi/Dockerfile new file mode 100644 index 000000000..1f87881f4 --- /dev/null +++ b/public/dockerfiles/mongodb-agent/11.0.12.7051-1/ubi/Dockerfile @@ -0,0 +1,46 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG agent_version + +LABEL name="MongoDB Agent" \ + version="${agent_version}" \ + summary="MongoDB Agent" \ + description="MongoDB Agent" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +RUN microdnf install -y --disableplugin=subscription-manager curl \ + hostname nss_wrapper tar gzip procps\ + && microdnf upgrade -y \ + && rm -rf /var/lib/apt/lists/* + +RUN microdnf remove perl-IO-Socket-SSL +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent +COPY --from=base /data/LICENSE /licenses/LICENSE + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/11.0.12.7051-1/ubuntu/Dockerfile b/public/dockerfiles/mongodb-agent/11.0.12.7051-1/ubuntu/Dockerfile new file mode 100644 index 000000000..f96eaa869 --- /dev/null +++ b/public/dockerfiles/mongodb-agent/11.0.12.7051-1/ubuntu/Dockerfile @@ -0,0 +1,47 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + +ARG agent_version + +LABEL name="MongoDB Agent" \ + version="${agent_version}" \ + summary="MongoDB Agent" \ + description="MongoDB Agent" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + curl \ + libnss-wrapper \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent +COPY --from=base /data/LICENSE /licenses/LICENSE + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/11.0.13.7055-1/ubi/Dockerfile b/public/dockerfiles/mongodb-agent/11.0.13.7055-1/ubi/Dockerfile new file mode 100644 index 000000000..1f87881f4 --- /dev/null +++ b/public/dockerfiles/mongodb-agent/11.0.13.7055-1/ubi/Dockerfile @@ -0,0 +1,46 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG agent_version + +LABEL name="MongoDB Agent" \ + version="${agent_version}" \ + summary="MongoDB Agent" \ + description="MongoDB Agent" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +RUN microdnf install -y --disableplugin=subscription-manager curl \ + hostname nss_wrapper tar gzip procps\ + && microdnf upgrade -y \ + && rm -rf /var/lib/apt/lists/* + +RUN microdnf remove perl-IO-Socket-SSL +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent +COPY --from=base /data/LICENSE /licenses/LICENSE + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/11.0.13.7055-1/ubuntu/Dockerfile b/public/dockerfiles/mongodb-agent/11.0.13.7055-1/ubuntu/Dockerfile new file mode 100644 index 000000000..f96eaa869 --- /dev/null +++ b/public/dockerfiles/mongodb-agent/11.0.13.7055-1/ubuntu/Dockerfile @@ -0,0 +1,47 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + +ARG agent_version + +LABEL name="MongoDB Agent" \ + version="${agent_version}" \ + summary="MongoDB Agent" \ + description="MongoDB Agent" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + curl \ + libnss-wrapper \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent +COPY --from=base /data/LICENSE /licenses/LICENSE + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/11.0.14.7064-1/ubi/Dockerfile b/public/dockerfiles/mongodb-agent/11.0.14.7064-1/ubi/Dockerfile new file mode 100644 index 000000000..1f87881f4 --- /dev/null +++ b/public/dockerfiles/mongodb-agent/11.0.14.7064-1/ubi/Dockerfile @@ -0,0 +1,46 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG agent_version + +LABEL name="MongoDB Agent" \ + version="${agent_version}" \ + summary="MongoDB Agent" \ + description="MongoDB Agent" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +RUN microdnf install -y --disableplugin=subscription-manager curl \ + hostname nss_wrapper tar gzip procps\ + && microdnf upgrade -y \ + && rm -rf /var/lib/apt/lists/* + +RUN microdnf remove perl-IO-Socket-SSL +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent +COPY --from=base /data/LICENSE /licenses/LICENSE + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/11.0.14.7064-1/ubuntu/Dockerfile b/public/dockerfiles/mongodb-agent/11.0.14.7064-1/ubuntu/Dockerfile new file mode 100644 index 000000000..f96eaa869 --- /dev/null +++ b/public/dockerfiles/mongodb-agent/11.0.14.7064-1/ubuntu/Dockerfile @@ -0,0 +1,47 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + +ARG agent_version + +LABEL name="MongoDB Agent" \ + version="${agent_version}" \ + summary="MongoDB Agent" \ + description="MongoDB Agent" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + curl \ + libnss-wrapper \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent +COPY --from=base /data/LICENSE /licenses/LICENSE + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/11.0.15.7073-1/ubi/Dockerfile b/public/dockerfiles/mongodb-agent/11.0.15.7073-1/ubi/Dockerfile new file mode 100644 index 000000000..1f87881f4 --- /dev/null +++ b/public/dockerfiles/mongodb-agent/11.0.15.7073-1/ubi/Dockerfile @@ -0,0 +1,46 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG agent_version + +LABEL name="MongoDB Agent" \ + version="${agent_version}" \ + summary="MongoDB Agent" \ + description="MongoDB Agent" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +RUN microdnf install -y --disableplugin=subscription-manager curl \ + hostname nss_wrapper tar gzip procps\ + && microdnf upgrade -y \ + && rm -rf /var/lib/apt/lists/* + +RUN microdnf remove perl-IO-Socket-SSL +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent +COPY --from=base /data/LICENSE /licenses/LICENSE + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/11.0.15.7073-1/ubuntu/Dockerfile b/public/dockerfiles/mongodb-agent/11.0.15.7073-1/ubuntu/Dockerfile new file mode 100644 index 000000000..f96eaa869 --- /dev/null +++ b/public/dockerfiles/mongodb-agent/11.0.15.7073-1/ubuntu/Dockerfile @@ -0,0 +1,47 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + +ARG agent_version + +LABEL name="MongoDB Agent" \ + version="${agent_version}" \ + summary="MongoDB Agent" \ + description="MongoDB Agent" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + curl \ + libnss-wrapper \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent +COPY --from=base /data/LICENSE /licenses/LICENSE + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/11.0.16.7080-1/ubi/Dockerfile b/public/dockerfiles/mongodb-agent/11.0.16.7080-1/ubi/Dockerfile new file mode 100644 index 000000000..1f87881f4 --- /dev/null +++ b/public/dockerfiles/mongodb-agent/11.0.16.7080-1/ubi/Dockerfile @@ -0,0 +1,46 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG agent_version + +LABEL name="MongoDB Agent" \ + version="${agent_version}" \ + summary="MongoDB Agent" \ + description="MongoDB Agent" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +RUN microdnf install -y --disableplugin=subscription-manager curl \ + hostname nss_wrapper tar gzip procps\ + && microdnf upgrade -y \ + && rm -rf /var/lib/apt/lists/* + +RUN microdnf remove perl-IO-Socket-SSL +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent +COPY --from=base /data/LICENSE /licenses/LICENSE + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/11.0.16.7080-1/ubuntu/Dockerfile b/public/dockerfiles/mongodb-agent/11.0.16.7080-1/ubuntu/Dockerfile new file mode 100644 index 000000000..f96eaa869 --- /dev/null +++ b/public/dockerfiles/mongodb-agent/11.0.16.7080-1/ubuntu/Dockerfile @@ -0,0 +1,47 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + +ARG agent_version + +LABEL name="MongoDB Agent" \ + version="${agent_version}" \ + summary="MongoDB Agent" \ + description="MongoDB Agent" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + curl \ + libnss-wrapper \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent +COPY --from=base /data/LICENSE /licenses/LICENSE + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/11.0.17.7084-1/ubi/Dockerfile b/public/dockerfiles/mongodb-agent/11.0.17.7084-1/ubi/Dockerfile new file mode 100644 index 000000000..d6e2c162c --- /dev/null +++ b/public/dockerfiles/mongodb-agent/11.0.17.7084-1/ubi/Dockerfile @@ -0,0 +1,45 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG agent_version + +LABEL name="MongoDB Agent" \ + version="${agent_version}" \ + summary="MongoDB Agent" \ + description="MongoDB Agent" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +RUN microdnf install -y --disableplugin=subscription-manager curl \ + hostname nss_wrapper tar gzip procps\ + && microdnf upgrade -y \ + && rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent +COPY --from=base /data/LICENSE /licenses/LICENSE + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/11.0.17.7084-1/ubuntu/Dockerfile b/public/dockerfiles/mongodb-agent/11.0.17.7084-1/ubuntu/Dockerfile new file mode 100644 index 000000000..f96eaa869 --- /dev/null +++ b/public/dockerfiles/mongodb-agent/11.0.17.7084-1/ubuntu/Dockerfile @@ -0,0 +1,47 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + +ARG agent_version + +LABEL name="MongoDB Agent" \ + version="${agent_version}" \ + summary="MongoDB Agent" \ + description="MongoDB Agent" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + curl \ + libnss-wrapper \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent +COPY --from=base /data/LICENSE /licenses/LICENSE + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/11.0.19.7094-1/ubi/Dockerfile b/public/dockerfiles/mongodb-agent/11.0.19.7094-1/ubi/Dockerfile new file mode 100644 index 000000000..d6e2c162c --- /dev/null +++ b/public/dockerfiles/mongodb-agent/11.0.19.7094-1/ubi/Dockerfile @@ -0,0 +1,45 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG agent_version + +LABEL name="MongoDB Agent" \ + version="${agent_version}" \ + summary="MongoDB Agent" \ + description="MongoDB Agent" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +RUN microdnf install -y --disableplugin=subscription-manager curl \ + hostname nss_wrapper tar gzip procps\ + && microdnf upgrade -y \ + && rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent +COPY --from=base /data/LICENSE /licenses/LICENSE + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/11.0.19.7094-1/ubuntu/Dockerfile b/public/dockerfiles/mongodb-agent/11.0.19.7094-1/ubuntu/Dockerfile new file mode 100644 index 000000000..f96eaa869 --- /dev/null +++ b/public/dockerfiles/mongodb-agent/11.0.19.7094-1/ubuntu/Dockerfile @@ -0,0 +1,47 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + +ARG agent_version + +LABEL name="MongoDB Agent" \ + version="${agent_version}" \ + summary="MongoDB Agent" \ + description="MongoDB Agent" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + curl \ + libnss-wrapper \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent +COPY --from=base /data/LICENSE /licenses/LICENSE + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/11.0.5.6963-1/ubi/Dockerfile b/public/dockerfiles/mongodb-agent/11.0.5.6963-1/ubi/Dockerfile new file mode 100644 index 000000000..1ae2cf2b3 --- /dev/null +++ b/public/dockerfiles/mongodb-agent/11.0.5.6963-1/ubi/Dockerfile @@ -0,0 +1,44 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi7/ubi + +ARG agent_version + +LABEL name="MongoDB Agent" \ + version="${agent_version}" \ + summary="MongoDB Agent" \ + description="MongoDB Agent" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +RUN yum install -y --disableplugin=subscription-manager -q curl \ + hostname nss_wrapper --exclude perl-IO-Socket-SSL procps \ + && yum upgrade -y -q \ + && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent +COPY --from=base /data/LICENSE /licenses/LICENSE + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/11.0.5.6963-1/ubuntu/Dockerfile b/public/dockerfiles/mongodb-agent/11.0.5.6963-1/ubuntu/Dockerfile new file mode 100644 index 000000000..f96eaa869 --- /dev/null +++ b/public/dockerfiles/mongodb-agent/11.0.5.6963-1/ubuntu/Dockerfile @@ -0,0 +1,47 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + +ARG agent_version + +LABEL name="MongoDB Agent" \ + version="${agent_version}" \ + summary="MongoDB Agent" \ + description="MongoDB Agent" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + curl \ + libnss-wrapper \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent +COPY --from=base /data/LICENSE /licenses/LICENSE + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/11.12.0.7388-1/ubi/Dockerfile b/public/dockerfiles/mongodb-agent/11.12.0.7388-1/ubi/Dockerfile new file mode 100644 index 000000000..1f87881f4 --- /dev/null +++ b/public/dockerfiles/mongodb-agent/11.12.0.7388-1/ubi/Dockerfile @@ -0,0 +1,46 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG agent_version + +LABEL name="MongoDB Agent" \ + version="${agent_version}" \ + summary="MongoDB Agent" \ + description="MongoDB Agent" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +RUN microdnf install -y --disableplugin=subscription-manager curl \ + hostname nss_wrapper tar gzip procps\ + && microdnf upgrade -y \ + && rm -rf /var/lib/apt/lists/* + +RUN microdnf remove perl-IO-Socket-SSL +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent +COPY --from=base /data/LICENSE /licenses/LICENSE + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/11.12.0.7388-1/ubuntu/Dockerfile b/public/dockerfiles/mongodb-agent/11.12.0.7388-1/ubuntu/Dockerfile new file mode 100644 index 000000000..f96eaa869 --- /dev/null +++ b/public/dockerfiles/mongodb-agent/11.12.0.7388-1/ubuntu/Dockerfile @@ -0,0 +1,47 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + +ARG agent_version + +LABEL name="MongoDB Agent" \ + version="${agent_version}" \ + summary="MongoDB Agent" \ + description="MongoDB Agent" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + curl \ + libnss-wrapper \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent +COPY --from=base /data/LICENSE /licenses/LICENSE + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/12.0.10.7591-1/ubi/Dockerfile b/public/dockerfiles/mongodb-agent/12.0.10.7591-1/ubi/Dockerfile new file mode 100644 index 000000000..d6e2c162c --- /dev/null +++ b/public/dockerfiles/mongodb-agent/12.0.10.7591-1/ubi/Dockerfile @@ -0,0 +1,45 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG agent_version + +LABEL name="MongoDB Agent" \ + version="${agent_version}" \ + summary="MongoDB Agent" \ + description="MongoDB Agent" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +RUN microdnf install -y --disableplugin=subscription-manager curl \ + hostname nss_wrapper tar gzip procps\ + && microdnf upgrade -y \ + && rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent +COPY --from=base /data/LICENSE /licenses/LICENSE + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/12.0.11.7606-1/ubi/Dockerfile b/public/dockerfiles/mongodb-agent/12.0.11.7606-1/ubi/Dockerfile new file mode 100644 index 000000000..d6e2c162c --- /dev/null +++ b/public/dockerfiles/mongodb-agent/12.0.11.7606-1/ubi/Dockerfile @@ -0,0 +1,45 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG agent_version + +LABEL name="MongoDB Agent" \ + version="${agent_version}" \ + summary="MongoDB Agent" \ + description="MongoDB Agent" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +RUN microdnf install -y --disableplugin=subscription-manager curl \ + hostname nss_wrapper tar gzip procps\ + && microdnf upgrade -y \ + && rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent +COPY --from=base /data/LICENSE /licenses/LICENSE + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/12.0.15.7646-1/ubi/Dockerfile b/public/dockerfiles/mongodb-agent/12.0.15.7646-1/ubi/Dockerfile new file mode 100644 index 000000000..d6e2c162c --- /dev/null +++ b/public/dockerfiles/mongodb-agent/12.0.15.7646-1/ubi/Dockerfile @@ -0,0 +1,45 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG agent_version + +LABEL name="MongoDB Agent" \ + version="${agent_version}" \ + summary="MongoDB Agent" \ + description="MongoDB Agent" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +RUN microdnf install -y --disableplugin=subscription-manager curl \ + hostname nss_wrapper tar gzip procps\ + && microdnf upgrade -y \ + && rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent +COPY --from=base /data/LICENSE /licenses/LICENSE + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/12.0.4.7554-1/ubi/Dockerfile b/public/dockerfiles/mongodb-agent/12.0.4.7554-1/ubi/Dockerfile new file mode 100644 index 000000000..1f87881f4 --- /dev/null +++ b/public/dockerfiles/mongodb-agent/12.0.4.7554-1/ubi/Dockerfile @@ -0,0 +1,46 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG agent_version + +LABEL name="MongoDB Agent" \ + version="${agent_version}" \ + summary="MongoDB Agent" \ + description="MongoDB Agent" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +RUN microdnf install -y --disableplugin=subscription-manager curl \ + hostname nss_wrapper tar gzip procps\ + && microdnf upgrade -y \ + && rm -rf /var/lib/apt/lists/* + +RUN microdnf remove perl-IO-Socket-SSL +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent +COPY --from=base /data/LICENSE /licenses/LICENSE + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/12.0.4.7554-1/ubuntu/Dockerfile b/public/dockerfiles/mongodb-agent/12.0.4.7554-1/ubuntu/Dockerfile new file mode 100644 index 000000000..f96eaa869 --- /dev/null +++ b/public/dockerfiles/mongodb-agent/12.0.4.7554-1/ubuntu/Dockerfile @@ -0,0 +1,47 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + +ARG agent_version + +LABEL name="MongoDB Agent" \ + version="${agent_version}" \ + summary="MongoDB Agent" \ + description="MongoDB Agent" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + curl \ + libnss-wrapper \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent +COPY --from=base /data/LICENSE /licenses/LICENSE + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/12.0.8.7575-1/ubi/Dockerfile b/public/dockerfiles/mongodb-agent/12.0.8.7575-1/ubi/Dockerfile new file mode 100644 index 000000000..1f87881f4 --- /dev/null +++ b/public/dockerfiles/mongodb-agent/12.0.8.7575-1/ubi/Dockerfile @@ -0,0 +1,46 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG agent_version + +LABEL name="MongoDB Agent" \ + version="${agent_version}" \ + summary="MongoDB Agent" \ + description="MongoDB Agent" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +RUN microdnf install -y --disableplugin=subscription-manager curl \ + hostname nss_wrapper tar gzip procps\ + && microdnf upgrade -y \ + && rm -rf /var/lib/apt/lists/* + +RUN microdnf remove perl-IO-Socket-SSL +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent +COPY --from=base /data/LICENSE /licenses/LICENSE + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-agent/12.0.8.7575-1/ubuntu/Dockerfile b/public/dockerfiles/mongodb-agent/12.0.8.7575-1/ubuntu/Dockerfile new file mode 100644 index 000000000..f96eaa869 --- /dev/null +++ b/public/dockerfiles/mongodb-agent/12.0.8.7575-1/ubuntu/Dockerfile @@ -0,0 +1,47 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + +ARG agent_version + +LABEL name="MongoDB Agent" \ + version="${agent_version}" \ + summary="MongoDB Agent" \ + description="MongoDB Agent" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + curl \ + libnss-wrapper \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /agent \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && mkdir -p /var/log/mongodb-mms-automation/ \ + && chmod -R +wr /var/log/mongodb-mms-automation/ \ + # ensure that the agent user can write the logs in OpenShift + && touch /var/log/mongodb-mms-automation/readiness.log \ + && chmod ugo+rw /var/log/mongodb-mms-automation/readiness.log + + +COPY --from=base /data/mongodb-agent.tar.gz /agent +COPY --from=base /data/mongodb-tools.tgz /agent +COPY --from=base /data/LICENSE /licenses/LICENSE + +RUN tar xfz /agent/mongodb-agent.tar.gz \ + && mv mongodb-mms-automation-agent-*/mongodb-mms-automation-agent /agent/mongodb-agent \ + && chmod +x /agent/mongodb-agent \ + && mkdir -p /var/lib/automation/config \ + && chmod -R +r /var/lib/automation/config \ + && rm /agent/mongodb-agent.tar.gz \ + && rm -r mongodb-mms-automation-agent-* + +RUN tar xfz /agent/mongodb-tools.tgz --directory /var/lib/mongodb-mms-automation/ && rm /agent/mongodb-tools.tgz + +USER 2000 +CMD ["/agent/mongodb-agent", "-cluster=/var/lib/automation/config/automation-config.json"] \ No newline at end of file diff --git a/public/dockerfiles/mongodb-enterprise-appdb/10.2.15.5958-1_4.2.11-ent/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-appdb/10.2.15.5958-1_4.2.11-ent/ubi/Dockerfile new file mode 100644 index 000000000..aa4d4818e --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-appdb/10.2.15.5958-1_4.2.11-ent/ubi/Dockerfile @@ -0,0 +1,112 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi + + + + +LABEL name="MongoDB Enterprise AppDB" \ + version="10.2.15.5958-1_4.2.11-ent" \ + summary="MongoDB Enterprise AppDB" \ + description="MongoDB Enterprise AppDB" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + + + + + + +ENV MMS_HOME /mongodb-automation +ENV MMS_LOG_DIR /var/log/mongodb-mms-automation + + + + + + + +# Download and extract the MongoDB archive and put it where the automation agent +# expects to find it. The Mongodb agent will download MongoDB from the Internet +# only if the version does not match the version we have put inside the image. + +COPY --from=base /data/mongodb_server_ubi.tgz /tmp +RUN mkdir -p /var/lib/mongodb-mms-automation/downloads \ + && tar -xzf "/tmp/mongodb_server_ubi.tgz" --directory "/var/lib/mongodb-mms-automation/downloads" \ + && rm "/tmp/mongodb_server_ubi.tgz" \ + && chmod -R 0775 "/var/lib/mongodb-mms-automation/downloads" + +COPY --from=base /data/mongodb_agent_linux.tgz /tmp +RUN mkdir -p ${MMS_HOME}/files/ \ + && tar -xzf /tmp/mongodb_agent_linux.tgz --directory / \ + && mv /mongodb-mms-automation-agent-*/mongodb-mms-automation-agent "${MMS_HOME}/files/" \ + && chmod +x "${MMS_HOME}/files/mongodb-mms-automation-agent" \ + && rm -rf /tmp/mongodb_agent_linux.tgz "/mongodb-mms-automation-agent-*/" + +RUN echo "10.2.15.5958-1" > ${MMS_HOME}/files/agent-version + + + + + +RUN yum update -y && rm -rf /var/cache/yum + +# these are the packages needed for the agent +RUN yum install -y --disableplugin=subscription-manager \ + hostname \ + nss_wrapper --exclude perl-IO-Socket-SSL \ + procps + + +# these are the packages needed for MongoDB +# (https://docs.mongodb.com/manual/tutorial/install-mongodb-enterprise-on-red-hat-tarball/ "RHEL/CentOS 8" tab) +RUN yum install -y --disableplugin=subscription-manager \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + jq + + +RUN ln -s /usr/lib64/libsasl2.so.3 /usr/lib64/libsasl2.so.2 + + +# Set the required perms +RUN mkdir -p "${MMS_LOG_DIR}" \ + && chmod 0775 "${MMS_LOG_DIR}" \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && chmod 0775 /var/lib/mongodb-mms-automation \ + && mkdir -p /data \ + && chmod 0775 /data \ + && mkdir -p /journal \ + && chmod 0775 /journal \ + && mkdir -p "${MMS_HOME}" \ + && chmod -R 0775 "${MMS_HOME}" + + + + +# USER needs to be set for this image to pass RedHat verification. Some customers have these requirements as well +# It does not matter what number it is, as long as it is set to something. +# However, OpenShift will run the container as a random user, +# and the number in this configuration is not relevant. +USER 2000 + + +# The docker image doesn't have any scripts so by default does nothing +# The script will be copied in runtime from init containers and the operator is expected +# to override the COMMAND +ENTRYPOINT ["sleep infinity"] + + +COPY --from=base /data/licenses/mongodb-enterprise-database /licenses/mongodb-enterprise-database + + diff --git a/public/dockerfiles/mongodb-enterprise-appdb/10.2.15.5958-1_4.2.11-ent/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-appdb/10.2.15.5958-1_4.2.11-ent/ubuntu/Dockerfile new file mode 100644 index 000000000..061bc04b9 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-appdb/10.2.15.5958-1_4.2.11-ent/ubuntu/Dockerfile @@ -0,0 +1,103 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:xenial-20210114 + + + + +LABEL name="MongoDB Enterprise AppDB" \ + version="10.2.15.5958-1_4.2.11-ent" \ + summary="MongoDB Enterprise AppDB" \ + description="MongoDB Enterprise AppDB" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + + + + + + +ENV MMS_HOME /mongodb-automation +ENV MMS_LOG_DIR /var/log/mongodb-mms-automation + + + + + + + +# Download and extract the MongoDB archive and put it where the automation agent +# expects to find it. The Mongodb agent will download MongoDB from the Internet +# only if the version does not match the version we have put inside the image. + +COPY --from=base /data/mongodb_server_ubuntu.tgz /tmp +RUN mkdir -p /var/lib/mongodb-mms-automation/downloads \ + && tar -xzf "/tmp/mongodb_server_ubuntu.tgz" --directory "/var/lib/mongodb-mms-automation/downloads" \ + && rm "/tmp/mongodb_server_ubuntu.tgz" \ + && chmod -R 0775 "/var/lib/mongodb-mms-automation/downloads" + +COPY --from=base /data/mongodb_agent_linux.tgz /tmp +RUN mkdir -p ${MMS_HOME}/files/ \ + && tar -xzf /tmp/mongodb_agent_linux.tgz --directory / \ + && mv /mongodb-mms-automation-agent-*/mongodb-mms-automation-agent "${MMS_HOME}/files/" \ + && chmod +x "${MMS_HOME}/files/mongodb-mms-automation-agent" \ + && rm -rf /tmp/mongodb_agent_linux.tgz "/mongodb-mms-automation-agent-*/" + +RUN echo "10.2.15.5958-1" > ${MMS_HOME}/files/agent-version + + + + + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + curl \ + jq \ + libcurl3 \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libsasl2-2 \ + lsb-release \ + openssl \ + snmp \ + libnss-wrapper \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + +# Set the required perms +RUN mkdir -p "${MMS_LOG_DIR}" \ + && chmod 0775 "${MMS_LOG_DIR}" \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && chmod 0775 /var/lib/mongodb-mms-automation \ + && mkdir -p /data \ + && chmod 0775 /data \ + && mkdir -p /journal \ + && chmod 0775 /journal \ + && mkdir -p "${MMS_HOME}" \ + && chmod -R 0775 "${MMS_HOME}" + + + + +# USER needs to be set for this image to pass RedHat verification. Some customers have these requirements as well +# It does not matter what number it is, as long as it is set to something. +# However, OpenShift will run the container as a random user, +# and the number in this configuration is not relevant. +USER 2000 + + +# The docker image doesn't have any scripts so by default does nothing +# The script will be copied in runtime from init containers and the operator is expected +# to override the COMMAND +ENTRYPOINT ["sleep infinity"] + + +COPY --from=base /data/licenses/mongodb-enterprise-database /licenses/mongodb-enterprise-database + + diff --git a/public/dockerfiles/mongodb-enterprise-database/2.0.0/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-database/2.0.0/ubi/Dockerfile new file mode 100644 index 000000000..9b6a7c60e --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-database/2.0.0/ubi/Dockerfile @@ -0,0 +1,89 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi + + + + +LABEL name="MongoDB Enterprise Database" \ + version="2.0.0" \ + summary="MongoDB Enterprise Database Image" \ + description="MongoDB Enterprise Database Image" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + + + + + + +ENV MMS_HOME /mongodb-automation +ENV MMS_LOG_DIR /var/log/mongodb-mms-automation + + + + + + + +RUN yum update -y && rm -rf /var/cache/yum + +# these are the packages needed for the agent +RUN yum install -y --disableplugin=subscription-manager \ + hostname \ + nss_wrapper --exclude perl-IO-Socket-SSL \ + procps + + +# these are the packages needed for MongoDB +# (https://docs.mongodb.com/manual/tutorial/install-mongodb-enterprise-on-red-hat-tarball/ "RHEL/CentOS 8" tab) +RUN yum install -y --disableplugin=subscription-manager \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + jq + + +RUN ln -s /usr/lib64/libsasl2.so.3 /usr/lib64/libsasl2.so.2 + + +# Set the required perms +RUN mkdir -p "${MMS_LOG_DIR}" \ + && chmod 0775 "${MMS_LOG_DIR}" \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && chmod 0775 /var/lib/mongodb-mms-automation \ + && mkdir -p /data \ + && chmod 0775 /data \ + && mkdir -p /journal \ + && chmod 0775 /journal \ + && mkdir -p "${MMS_HOME}" \ + && chmod -R 0775 "${MMS_HOME}" + + + + +# USER needs to be set for this image to pass RedHat verification. Some customers have these requirements as well +# It does not matter what number it is, as long as it is set to something. +# However, OpenShift will run the container as a random user, +# and the number in this configuration is not relevant. +USER 2000 + + +# The docker image doesn't have any scripts so by default does nothing +# The script will be copied in runtime from init containers and the operator is expected +# to override the COMMAND +ENTRYPOINT ["sleep infinity"] + + +COPY --from=base /data/licenses/mongodb-enterprise-database /licenses/mongodb-enterprise-database + + diff --git a/public/dockerfiles/mongodb-enterprise-database/2.0.0/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-database/2.0.0/ubuntu/Dockerfile new file mode 100644 index 000000000..b9156971e --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-database/2.0.0/ubuntu/Dockerfile @@ -0,0 +1,80 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:xenial-20210114 + + + + +LABEL name="MongoDB Enterprise Database" \ + version="2.0.0" \ + summary="MongoDB Enterprise Database Image" \ + description="MongoDB Enterprise Database Image" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + + + + + + +ENV MMS_HOME /mongodb-automation +ENV MMS_LOG_DIR /var/log/mongodb-mms-automation + + + + + + + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + curl \ + jq \ + libcurl3 \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libsasl2-2 \ + lsb-release \ + openssl \ + snmp \ + libnss-wrapper \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + +# Set the required perms +RUN mkdir -p "${MMS_LOG_DIR}" \ + && chmod 0775 "${MMS_LOG_DIR}" \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && chmod 0775 /var/lib/mongodb-mms-automation \ + && mkdir -p /data \ + && chmod 0775 /data \ + && mkdir -p /journal \ + && chmod 0775 /journal \ + && mkdir -p "${MMS_HOME}" \ + && chmod -R 0775 "${MMS_HOME}" + + + + +# USER needs to be set for this image to pass RedHat verification. Some customers have these requirements as well +# It does not matter what number it is, as long as it is set to something. +# However, OpenShift will run the container as a random user, +# and the number in this configuration is not relevant. +USER 2000 + + +# The docker image doesn't have any scripts so by default does nothing +# The script will be copied in runtime from init containers and the operator is expected +# to override the COMMAND +ENTRYPOINT ["sleep infinity"] + + +COPY --from=base /data/licenses/mongodb-enterprise-database /licenses/mongodb-enterprise-database + + diff --git a/public/dockerfiles/mongodb-enterprise-database/2.0.1/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-database/2.0.1/ubi/Dockerfile new file mode 100644 index 000000000..e79556a43 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-database/2.0.1/ubi/Dockerfile @@ -0,0 +1,87 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + + +LABEL name="MongoDB Enterprise Database" \ + version="2.0.1" \ + summary="MongoDB Enterprise Database Image" \ + description="MongoDB Enterprise Database Image" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + + + + + +ENV MMS_HOME /mongodb-automation +ENV MMS_LOG_DIR /var/log/mongodb-mms-automation + + + +RUN microdnf update -y && rm -rf /var/cache/yum + +# these are the packages needed for the agent +RUN microdnf install -y --disableplugin=subscription-manager \ + hostname \ + nss_wrapper \ + procps + + +# these are the packages needed for MongoDB +# (https://docs.mongodb.com/manual/tutorial/install-mongodb-enterprise-on-red-hat-tarball/ "RHEL/CentOS 8" tab) +RUN microdnf install -y --disableplugin=subscription-manager \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + jq \ + tar \ + findutils + + +RUN microdnf remove perl-IO-Socket-SSL + +RUN ln -s /usr/lib64/libsasl2.so.3 /usr/lib64/libsasl2.so.2 + + +# Set the required perms +RUN mkdir -p "${MMS_LOG_DIR}" \ + && chmod 0775 "${MMS_LOG_DIR}" \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && chmod 0775 /var/lib/mongodb-mms-automation \ + && mkdir -p /data \ + && chmod 0775 /data \ + && mkdir -p /journal \ + && chmod 0775 /journal \ + && mkdir -p "${MMS_HOME}" \ + && chmod -R 0775 "${MMS_HOME}" + + + + +# USER needs to be set for this image to pass RedHat verification. Some customers have these requirements as well +# It does not matter what number it is, as long as it is set to something. +# However, OpenShift will run the container as a random user, +# and the number in this configuration is not relevant. +USER 2000 + + +# The docker image doesn't have any scripts so by default does nothing +# The script will be copied in runtime from init containers and the operator is expected +# to override the COMMAND +ENTRYPOINT ["sleep infinity"] + + +COPY --from=base /data/licenses/mongodb-enterprise-database /licenses/mongodb-enterprise-database + + diff --git a/public/dockerfiles/mongodb-enterprise-database/2.0.1/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-database/2.0.1/ubuntu/Dockerfile new file mode 100644 index 000000000..c4a254795 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-database/2.0.1/ubuntu/Dockerfile @@ -0,0 +1,78 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:18.04 + + + +LABEL name="MongoDB Enterprise Database" \ + version="2.0.1" \ + summary="MongoDB Enterprise Database Image" \ + description="MongoDB Enterprise Database Image" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + + + + + +ENV MMS_HOME /mongodb-automation +ENV MMS_LOG_DIR /var/log/mongodb-mms-automation + + + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + curl \ + jq \ + libcurl4 \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + liblzma5 \ + libpcap0.8 \ + libsasl2-2 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit \ + libwrap0 \ + lsb-release \ + openssl \ + snmp \ + libnss-wrapper \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + +# Set the required perms +RUN mkdir -p "${MMS_LOG_DIR}" \ + && chmod 0775 "${MMS_LOG_DIR}" \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && chmod 0775 /var/lib/mongodb-mms-automation \ + && mkdir -p /data \ + && chmod 0775 /data \ + && mkdir -p /journal \ + && chmod 0775 /journal \ + && mkdir -p "${MMS_HOME}" \ + && chmod -R 0775 "${MMS_HOME}" + + + + +# USER needs to be set for this image to pass RedHat verification. Some customers have these requirements as well +# It does not matter what number it is, as long as it is set to something. +# However, OpenShift will run the container as a random user, +# and the number in this configuration is not relevant. +USER 2000 + + +# The docker image doesn't have any scripts so by default does nothing +# The script will be copied in runtime from init containers and the operator is expected +# to override the COMMAND +ENTRYPOINT ["sleep infinity"] + + +COPY --from=base /data/licenses/mongodb-enterprise-database /licenses/mongodb-enterprise-database + + diff --git a/public/dockerfiles/mongodb-enterprise-database/2.0.2/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-database/2.0.2/ubi/Dockerfile new file mode 100644 index 000000000..9d11c42d6 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-database/2.0.2/ubi/Dockerfile @@ -0,0 +1,87 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + + +LABEL name="MongoDB Enterprise Database" \ + version="2.0.2" \ + summary="MongoDB Enterprise Database Image" \ + description="MongoDB Enterprise Database Image" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + + + + + +ENV MMS_HOME /mongodb-automation +ENV MMS_LOG_DIR /var/log/mongodb-mms-automation + + + +RUN microdnf update -y && rm -rf /var/cache/yum + +# these are the packages needed for the agent +RUN microdnf install -y --disableplugin=subscription-manager \ + hostname \ + nss_wrapper \ + procps + + +# these are the packages needed for MongoDB +# (https://docs.mongodb.com/manual/tutorial/install-mongodb-enterprise-on-red-hat-tarball/ "RHEL/CentOS 8" tab) +RUN microdnf install -y --disableplugin=subscription-manager \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + jq \ + tar \ + findutils + + +RUN microdnf remove perl-IO-Socket-SSL + +RUN ln -s /usr/lib64/libsasl2.so.3 /usr/lib64/libsasl2.so.2 + + +# Set the required perms +RUN mkdir -p "${MMS_LOG_DIR}" \ + && chmod 0775 "${MMS_LOG_DIR}" \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && chmod 0775 /var/lib/mongodb-mms-automation \ + && mkdir -p /data \ + && chmod 0775 /data \ + && mkdir -p /journal \ + && chmod 0775 /journal \ + && mkdir -p "${MMS_HOME}" \ + && chmod -R 0775 "${MMS_HOME}" + + + + +# USER needs to be set for this image to pass RedHat verification. Some customers have these requirements as well +# It does not matter what number it is, as long as it is set to something. +# However, OpenShift will run the container as a random user, +# and the number in this configuration is not relevant. +USER 2000 + + +# The docker image doesn't have any scripts so by default does nothing +# The script will be copied in runtime from init containers and the operator is expected +# to override the COMMAND +ENTRYPOINT ["sleep infinity"] + + +COPY --from=base /data/licenses/mongodb-enterprise-database /licenses/mongodb-enterprise-database + + diff --git a/public/dockerfiles/mongodb-enterprise-database/2.0.2/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-database/2.0.2/ubuntu/Dockerfile new file mode 100644 index 000000000..14aa44f1d --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-database/2.0.2/ubuntu/Dockerfile @@ -0,0 +1,77 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:18.04 + + + +LABEL name="MongoDB Enterprise Database" \ + version="2.0.2" \ + summary="MongoDB Enterprise Database Image" \ + description="MongoDB Enterprise Database Image" \ + vendor="MongoDB" \ + release="1" \ + maintainer="support@mongodb.com" + + + + + +ENV MMS_HOME /mongodb-automation +ENV MMS_LOG_DIR /var/log/mongodb-mms-automation + + + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + curl \ + jq \ + libcurl4 \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + liblzma5 \ + libpcap0.8 \ + libsasl2-2 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit \ + libwrap0 \ + openssl \ + snmp \ + libnss-wrapper \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + +# Set the required perms +RUN mkdir -p "${MMS_LOG_DIR}" \ + && chmod 0775 "${MMS_LOG_DIR}" \ + && mkdir -p /var/lib/mongodb-mms-automation \ + && chmod 0775 /var/lib/mongodb-mms-automation \ + && mkdir -p /data \ + && chmod 0775 /data \ + && mkdir -p /journal \ + && chmod 0775 /journal \ + && mkdir -p "${MMS_HOME}" \ + && chmod -R 0775 "${MMS_HOME}" + + + + +# USER needs to be set for this image to pass RedHat verification. Some customers have these requirements as well +# It does not matter what number it is, as long as it is set to something. +# However, OpenShift will run the container as a random user, +# and the number in this configuration is not relevant. +USER 2000 + + +# The docker image doesn't have any scripts so by default does nothing +# The script will be copied in runtime from init containers and the operator is expected +# to override the COMMAND +ENTRYPOINT ["sleep infinity"] + + +COPY --from=base /data/licenses/mongodb-enterprise-database /licenses/mongodb-enterprise-database + + diff --git a/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.10/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.10/ubi/Dockerfile new file mode 100644 index 000000000..68ae7cc2c --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.10/ubi/Dockerfile @@ -0,0 +1,35 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG version +LABEL name="MongoDB Enterprise Init AppDB" \ + version="mongodb-enterprise-init-appdb-${version}" \ + summary="MongoDB Enterprise AppDB Init Image" \ + description="Startup Scripts for MongoDB Enterprise Application Database for Ops Manager" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ +COPY --from=base /data/version-upgrade-hook /probes/version-upgrade-hook + + +RUN microdnf update --nodocs \ + && microdnf -y install --nodocs tar gzip \ + && microdnf clean all + +COPY --from=base /data/mongodb_tools_ubi.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.10/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.10/ubuntu/Dockerfile new file mode 100644 index 000000000..829f09941 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.10/ubuntu/Dockerfile @@ -0,0 +1,31 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +ARG version +LABEL name="MongoDB Enterprise Init AppDB" \ + version="mongodb-enterprise-init-appdb-${version}" \ + summary="MongoDB Enterprise AppDB Init Image" \ + description="Startup Scripts for MongoDB Enterprise Application Database for Ops Manager" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ +COPY --from=base /data/version-upgrade-hook /probes/version-upgrade-hook + + +COPY --from=base /data/mongodb_tools_ubuntu.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.11/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.11/ubi/Dockerfile new file mode 100644 index 000000000..68ae7cc2c --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.11/ubi/Dockerfile @@ -0,0 +1,35 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG version +LABEL name="MongoDB Enterprise Init AppDB" \ + version="mongodb-enterprise-init-appdb-${version}" \ + summary="MongoDB Enterprise AppDB Init Image" \ + description="Startup Scripts for MongoDB Enterprise Application Database for Ops Manager" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ +COPY --from=base /data/version-upgrade-hook /probes/version-upgrade-hook + + +RUN microdnf update --nodocs \ + && microdnf -y install --nodocs tar gzip \ + && microdnf clean all + +COPY --from=base /data/mongodb_tools_ubi.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.11/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.11/ubuntu/Dockerfile new file mode 100644 index 000000000..829f09941 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.11/ubuntu/Dockerfile @@ -0,0 +1,31 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +ARG version +LABEL name="MongoDB Enterprise Init AppDB" \ + version="mongodb-enterprise-init-appdb-${version}" \ + summary="MongoDB Enterprise AppDB Init Image" \ + description="Startup Scripts for MongoDB Enterprise Application Database for Ops Manager" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ +COPY --from=base /data/version-upgrade-hook /probes/version-upgrade-hook + + +COPY --from=base /data/mongodb_tools_ubuntu.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.12/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.12/ubi/Dockerfile new file mode 100644 index 000000000..68ae7cc2c --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.12/ubi/Dockerfile @@ -0,0 +1,35 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG version +LABEL name="MongoDB Enterprise Init AppDB" \ + version="mongodb-enterprise-init-appdb-${version}" \ + summary="MongoDB Enterprise AppDB Init Image" \ + description="Startup Scripts for MongoDB Enterprise Application Database for Ops Manager" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ +COPY --from=base /data/version-upgrade-hook /probes/version-upgrade-hook + + +RUN microdnf update --nodocs \ + && microdnf -y install --nodocs tar gzip \ + && microdnf clean all + +COPY --from=base /data/mongodb_tools_ubi.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.12/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.12/ubuntu/Dockerfile new file mode 100644 index 000000000..829f09941 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.12/ubuntu/Dockerfile @@ -0,0 +1,31 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +ARG version +LABEL name="MongoDB Enterprise Init AppDB" \ + version="mongodb-enterprise-init-appdb-${version}" \ + summary="MongoDB Enterprise AppDB Init Image" \ + description="Startup Scripts for MongoDB Enterprise Application Database for Ops Manager" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ +COPY --from=base /data/version-upgrade-hook /probes/version-upgrade-hook + + +COPY --from=base /data/mongodb_tools_ubuntu.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.13/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.13/ubi/Dockerfile new file mode 100644 index 000000000..68ae7cc2c --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.13/ubi/Dockerfile @@ -0,0 +1,35 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG version +LABEL name="MongoDB Enterprise Init AppDB" \ + version="mongodb-enterprise-init-appdb-${version}" \ + summary="MongoDB Enterprise AppDB Init Image" \ + description="Startup Scripts for MongoDB Enterprise Application Database for Ops Manager" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ +COPY --from=base /data/version-upgrade-hook /probes/version-upgrade-hook + + +RUN microdnf update --nodocs \ + && microdnf -y install --nodocs tar gzip \ + && microdnf clean all + +COPY --from=base /data/mongodb_tools_ubi.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.13/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.13/ubuntu/Dockerfile new file mode 100644 index 000000000..829f09941 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.13/ubuntu/Dockerfile @@ -0,0 +1,31 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +ARG version +LABEL name="MongoDB Enterprise Init AppDB" \ + version="mongodb-enterprise-init-appdb-${version}" \ + summary="MongoDB Enterprise AppDB Init Image" \ + description="Startup Scripts for MongoDB Enterprise Application Database for Ops Manager" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ +COPY --from=base /data/version-upgrade-hook /probes/version-upgrade-hook + + +COPY --from=base /data/mongodb_tools_ubuntu.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.14/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.14/ubi/Dockerfile new file mode 100644 index 000000000..68ae7cc2c --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.14/ubi/Dockerfile @@ -0,0 +1,35 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG version +LABEL name="MongoDB Enterprise Init AppDB" \ + version="mongodb-enterprise-init-appdb-${version}" \ + summary="MongoDB Enterprise AppDB Init Image" \ + description="Startup Scripts for MongoDB Enterprise Application Database for Ops Manager" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ +COPY --from=base /data/version-upgrade-hook /probes/version-upgrade-hook + + +RUN microdnf update --nodocs \ + && microdnf -y install --nodocs tar gzip \ + && microdnf clean all + +COPY --from=base /data/mongodb_tools_ubi.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.14/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.14/ubuntu/Dockerfile new file mode 100644 index 000000000..829f09941 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.14/ubuntu/Dockerfile @@ -0,0 +1,31 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +ARG version +LABEL name="MongoDB Enterprise Init AppDB" \ + version="mongodb-enterprise-init-appdb-${version}" \ + summary="MongoDB Enterprise AppDB Init Image" \ + description="Startup Scripts for MongoDB Enterprise Application Database for Ops Manager" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ +COPY --from=base /data/version-upgrade-hook /probes/version-upgrade-hook + + +COPY --from=base /data/mongodb_tools_ubuntu.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.6/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.6/ubi/Dockerfile new file mode 100644 index 000000000..333487815 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.6/ubi/Dockerfile @@ -0,0 +1,31 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG version +LABEL name="MongoDB Enterprise Init AppDB" \ + version="mongodb-enterprise-init-appdb-${version}" \ + summary="MongoDB Enterprise AppDB Init Image" \ + description="Startup Scripts for MongoDB Enterprise Application Database for Ops Manager" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ + + +RUN microdnf -y install --nodocs tar gzip && microdnf clean all +COPY --from=base /data/mongodb_tools_ubi.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.6/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.6/ubuntu/Dockerfile new file mode 100644 index 000000000..70d7ac369 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.6/ubuntu/Dockerfile @@ -0,0 +1,30 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +ARG version +LABEL name="MongoDB Enterprise Init AppDB" \ + version="mongodb-enterprise-init-appdb-${version}" \ + summary="MongoDB Enterprise AppDB Init Image" \ + description="Startup Scripts for MongoDB Enterprise Application Database for Ops Manager" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ + + +COPY --from=base /data/mongodb_tools_ubuntu.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.7/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.7/ubi/Dockerfile new file mode 100644 index 000000000..68ae7cc2c --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.7/ubi/Dockerfile @@ -0,0 +1,35 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG version +LABEL name="MongoDB Enterprise Init AppDB" \ + version="mongodb-enterprise-init-appdb-${version}" \ + summary="MongoDB Enterprise AppDB Init Image" \ + description="Startup Scripts for MongoDB Enterprise Application Database for Ops Manager" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ +COPY --from=base /data/version-upgrade-hook /probes/version-upgrade-hook + + +RUN microdnf update --nodocs \ + && microdnf -y install --nodocs tar gzip \ + && microdnf clean all + +COPY --from=base /data/mongodb_tools_ubi.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.7/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.7/ubuntu/Dockerfile new file mode 100644 index 000000000..829f09941 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.7/ubuntu/Dockerfile @@ -0,0 +1,31 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +ARG version +LABEL name="MongoDB Enterprise Init AppDB" \ + version="mongodb-enterprise-init-appdb-${version}" \ + summary="MongoDB Enterprise AppDB Init Image" \ + description="Startup Scripts for MongoDB Enterprise Application Database for Ops Manager" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ +COPY --from=base /data/version-upgrade-hook /probes/version-upgrade-hook + + +COPY --from=base /data/mongodb_tools_ubuntu.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.8/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.8/ubi/Dockerfile new file mode 100644 index 000000000..68ae7cc2c --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.8/ubi/Dockerfile @@ -0,0 +1,35 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG version +LABEL name="MongoDB Enterprise Init AppDB" \ + version="mongodb-enterprise-init-appdb-${version}" \ + summary="MongoDB Enterprise AppDB Init Image" \ + description="Startup Scripts for MongoDB Enterprise Application Database for Ops Manager" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ +COPY --from=base /data/version-upgrade-hook /probes/version-upgrade-hook + + +RUN microdnf update --nodocs \ + && microdnf -y install --nodocs tar gzip \ + && microdnf clean all + +COPY --from=base /data/mongodb_tools_ubi.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.8/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.8/ubuntu/Dockerfile new file mode 100644 index 000000000..829f09941 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.8/ubuntu/Dockerfile @@ -0,0 +1,31 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +ARG version +LABEL name="MongoDB Enterprise Init AppDB" \ + version="mongodb-enterprise-init-appdb-${version}" \ + summary="MongoDB Enterprise AppDB Init Image" \ + description="Startup Scripts for MongoDB Enterprise Application Database for Ops Manager" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ +COPY --from=base /data/version-upgrade-hook /probes/version-upgrade-hook + + +COPY --from=base /data/mongodb_tools_ubuntu.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.9/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.9/ubi/Dockerfile new file mode 100644 index 000000000..68ae7cc2c --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.9/ubi/Dockerfile @@ -0,0 +1,35 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG version +LABEL name="MongoDB Enterprise Init AppDB" \ + version="mongodb-enterprise-init-appdb-${version}" \ + summary="MongoDB Enterprise AppDB Init Image" \ + description="Startup Scripts for MongoDB Enterprise Application Database for Ops Manager" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ +COPY --from=base /data/version-upgrade-hook /probes/version-upgrade-hook + + +RUN microdnf update --nodocs \ + && microdnf -y install --nodocs tar gzip \ + && microdnf clean all + +COPY --from=base /data/mongodb_tools_ubi.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.9/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.9/ubuntu/Dockerfile new file mode 100644 index 000000000..829f09941 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-appdb/1.0.9/ubuntu/Dockerfile @@ -0,0 +1,31 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +ARG version +LABEL name="MongoDB Enterprise Init AppDB" \ + version="mongodb-enterprise-init-appdb-${version}" \ + summary="MongoDB Enterprise AppDB Init Image" \ + description="Startup Scripts for MongoDB Enterprise Application Database for Ops Manager" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ +COPY --from=base /data/version-upgrade-hook /probes/version-upgrade-hook + + +COPY --from=base /data/mongodb_tools_ubuntu.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-database/1.0.10/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-database/1.0.10/ubi/Dockerfile new file mode 100644 index 000000000..eb481048c --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-database/1.0.10/ubi/Dockerfile @@ -0,0 +1,34 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG version +LABEL name="MongoDB Enterprise Init Database" \ + version="mongodb-enterprise-init-database-${version}" \ + summary="MongoDB Enterprise Database Init Image" \ + description="Startup Scripts for MongoDB Enterprise Database" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ + + +RUN microdnf update --nodocs \ + && microdnf -y install --nodocs tar gzip \ + && microdnf clean all + +COPY --from=base /data/mongodb_tools_ubi.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-database/1.0.10/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-database/1.0.10/ubuntu/Dockerfile new file mode 100644 index 000000000..569ad9fb8 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-database/1.0.10/ubuntu/Dockerfile @@ -0,0 +1,30 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +ARG version +LABEL name="MongoDB Enterprise Init Database" \ + version="mongodb-enterprise-init-database-${version}" \ + summary="MongoDB Enterprise Database Init Image" \ + description="Startup Scripts for MongoDB Enterprise Database" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ + + +COPY --from=base /data/mongodb_tools_ubuntu.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-database/1.0.11/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-database/1.0.11/ubi/Dockerfile new file mode 100644 index 000000000..eb481048c --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-database/1.0.11/ubi/Dockerfile @@ -0,0 +1,34 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG version +LABEL name="MongoDB Enterprise Init Database" \ + version="mongodb-enterprise-init-database-${version}" \ + summary="MongoDB Enterprise Database Init Image" \ + description="Startup Scripts for MongoDB Enterprise Database" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ + + +RUN microdnf update --nodocs \ + && microdnf -y install --nodocs tar gzip \ + && microdnf clean all + +COPY --from=base /data/mongodb_tools_ubi.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-database/1.0.11/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-database/1.0.11/ubuntu/Dockerfile new file mode 100644 index 000000000..569ad9fb8 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-database/1.0.11/ubuntu/Dockerfile @@ -0,0 +1,30 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +ARG version +LABEL name="MongoDB Enterprise Init Database" \ + version="mongodb-enterprise-init-database-${version}" \ + summary="MongoDB Enterprise Database Init Image" \ + description="Startup Scripts for MongoDB Enterprise Database" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ + + +COPY --from=base /data/mongodb_tools_ubuntu.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-database/1.0.12/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-database/1.0.12/ubi/Dockerfile new file mode 100644 index 000000000..eb481048c --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-database/1.0.12/ubi/Dockerfile @@ -0,0 +1,34 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG version +LABEL name="MongoDB Enterprise Init Database" \ + version="mongodb-enterprise-init-database-${version}" \ + summary="MongoDB Enterprise Database Init Image" \ + description="Startup Scripts for MongoDB Enterprise Database" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ + + +RUN microdnf update --nodocs \ + && microdnf -y install --nodocs tar gzip \ + && microdnf clean all + +COPY --from=base /data/mongodb_tools_ubi.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-database/1.0.12/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-database/1.0.12/ubuntu/Dockerfile new file mode 100644 index 000000000..569ad9fb8 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-database/1.0.12/ubuntu/Dockerfile @@ -0,0 +1,30 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +ARG version +LABEL name="MongoDB Enterprise Init Database" \ + version="mongodb-enterprise-init-database-${version}" \ + summary="MongoDB Enterprise Database Init Image" \ + description="Startup Scripts for MongoDB Enterprise Database" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ + + +COPY --from=base /data/mongodb_tools_ubuntu.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-database/1.0.13/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-database/1.0.13/ubi/Dockerfile new file mode 100644 index 000000000..eb481048c --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-database/1.0.13/ubi/Dockerfile @@ -0,0 +1,34 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG version +LABEL name="MongoDB Enterprise Init Database" \ + version="mongodb-enterprise-init-database-${version}" \ + summary="MongoDB Enterprise Database Init Image" \ + description="Startup Scripts for MongoDB Enterprise Database" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ + + +RUN microdnf update --nodocs \ + && microdnf -y install --nodocs tar gzip \ + && microdnf clean all + +COPY --from=base /data/mongodb_tools_ubi.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-database/1.0.13/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-database/1.0.13/ubuntu/Dockerfile new file mode 100644 index 000000000..569ad9fb8 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-database/1.0.13/ubuntu/Dockerfile @@ -0,0 +1,30 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +ARG version +LABEL name="MongoDB Enterprise Init Database" \ + version="mongodb-enterprise-init-database-${version}" \ + summary="MongoDB Enterprise Database Init Image" \ + description="Startup Scripts for MongoDB Enterprise Database" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ + + +COPY --from=base /data/mongodb_tools_ubuntu.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-database/1.0.14/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-database/1.0.14/ubi/Dockerfile new file mode 100644 index 000000000..eb481048c --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-database/1.0.14/ubi/Dockerfile @@ -0,0 +1,34 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG version +LABEL name="MongoDB Enterprise Init Database" \ + version="mongodb-enterprise-init-database-${version}" \ + summary="MongoDB Enterprise Database Init Image" \ + description="Startup Scripts for MongoDB Enterprise Database" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ + + +RUN microdnf update --nodocs \ + && microdnf -y install --nodocs tar gzip \ + && microdnf clean all + +COPY --from=base /data/mongodb_tools_ubi.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-database/1.0.14/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-database/1.0.14/ubuntu/Dockerfile new file mode 100644 index 000000000..569ad9fb8 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-database/1.0.14/ubuntu/Dockerfile @@ -0,0 +1,30 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +ARG version +LABEL name="MongoDB Enterprise Init Database" \ + version="mongodb-enterprise-init-database-${version}" \ + summary="MongoDB Enterprise Database Init Image" \ + description="Startup Scripts for MongoDB Enterprise Database" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ + + +COPY --from=base /data/mongodb_tools_ubuntu.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-database/1.0.2/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-database/1.0.2/ubi/Dockerfile new file mode 100644 index 000000000..45ac9d6d0 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-database/1.0.2/ubi/Dockerfile @@ -0,0 +1,31 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG version +LABEL name="MongoDB Enterprise Init Database" \ + version="mongodb-enterprise-init-database-${version}" \ + summary="MongoDB Enterprise Database Init Image" \ + description="Startup Scripts for MongoDB Enterprise Database" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ + + +RUN microdnf -y install --nodocs tar gzip && microdnf clean all +COPY --from=base /data/mongodb_tools_ubi.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-database/1.0.2/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-database/1.0.2/ubuntu/Dockerfile new file mode 100644 index 000000000..569ad9fb8 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-database/1.0.2/ubuntu/Dockerfile @@ -0,0 +1,30 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +ARG version +LABEL name="MongoDB Enterprise Init Database" \ + version="mongodb-enterprise-init-database-${version}" \ + summary="MongoDB Enterprise Database Init Image" \ + description="Startup Scripts for MongoDB Enterprise Database" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ + + +COPY --from=base /data/mongodb_tools_ubuntu.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-database/1.0.3/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-database/1.0.3/ubi/Dockerfile new file mode 100644 index 000000000..eb481048c --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-database/1.0.3/ubi/Dockerfile @@ -0,0 +1,34 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG version +LABEL name="MongoDB Enterprise Init Database" \ + version="mongodb-enterprise-init-database-${version}" \ + summary="MongoDB Enterprise Database Init Image" \ + description="Startup Scripts for MongoDB Enterprise Database" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ + + +RUN microdnf update --nodocs \ + && microdnf -y install --nodocs tar gzip \ + && microdnf clean all + +COPY --from=base /data/mongodb_tools_ubi.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-database/1.0.3/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-database/1.0.3/ubuntu/Dockerfile new file mode 100644 index 000000000..569ad9fb8 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-database/1.0.3/ubuntu/Dockerfile @@ -0,0 +1,30 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +ARG version +LABEL name="MongoDB Enterprise Init Database" \ + version="mongodb-enterprise-init-database-${version}" \ + summary="MongoDB Enterprise Database Init Image" \ + description="Startup Scripts for MongoDB Enterprise Database" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ + + +COPY --from=base /data/mongodb_tools_ubuntu.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-database/1.0.4/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-database/1.0.4/ubi/Dockerfile new file mode 100644 index 000000000..eb481048c --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-database/1.0.4/ubi/Dockerfile @@ -0,0 +1,34 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG version +LABEL name="MongoDB Enterprise Init Database" \ + version="mongodb-enterprise-init-database-${version}" \ + summary="MongoDB Enterprise Database Init Image" \ + description="Startup Scripts for MongoDB Enterprise Database" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ + + +RUN microdnf update --nodocs \ + && microdnf -y install --nodocs tar gzip \ + && microdnf clean all + +COPY --from=base /data/mongodb_tools_ubi.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-database/1.0.4/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-database/1.0.4/ubuntu/Dockerfile new file mode 100644 index 000000000..569ad9fb8 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-database/1.0.4/ubuntu/Dockerfile @@ -0,0 +1,30 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +ARG version +LABEL name="MongoDB Enterprise Init Database" \ + version="mongodb-enterprise-init-database-${version}" \ + summary="MongoDB Enterprise Database Init Image" \ + description="Startup Scripts for MongoDB Enterprise Database" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ + + +COPY --from=base /data/mongodb_tools_ubuntu.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-database/1.0.5/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-database/1.0.5/ubi/Dockerfile new file mode 100644 index 000000000..eb481048c --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-database/1.0.5/ubi/Dockerfile @@ -0,0 +1,34 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG version +LABEL name="MongoDB Enterprise Init Database" \ + version="mongodb-enterprise-init-database-${version}" \ + summary="MongoDB Enterprise Database Init Image" \ + description="Startup Scripts for MongoDB Enterprise Database" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ + + +RUN microdnf update --nodocs \ + && microdnf -y install --nodocs tar gzip \ + && microdnf clean all + +COPY --from=base /data/mongodb_tools_ubi.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-database/1.0.5/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-database/1.0.5/ubuntu/Dockerfile new file mode 100644 index 000000000..569ad9fb8 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-database/1.0.5/ubuntu/Dockerfile @@ -0,0 +1,30 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +ARG version +LABEL name="MongoDB Enterprise Init Database" \ + version="mongodb-enterprise-init-database-${version}" \ + summary="MongoDB Enterprise Database Init Image" \ + description="Startup Scripts for MongoDB Enterprise Database" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ + + +COPY --from=base /data/mongodb_tools_ubuntu.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-database/1.0.6/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-database/1.0.6/ubi/Dockerfile new file mode 100644 index 000000000..eb481048c --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-database/1.0.6/ubi/Dockerfile @@ -0,0 +1,34 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG version +LABEL name="MongoDB Enterprise Init Database" \ + version="mongodb-enterprise-init-database-${version}" \ + summary="MongoDB Enterprise Database Init Image" \ + description="Startup Scripts for MongoDB Enterprise Database" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ + + +RUN microdnf update --nodocs \ + && microdnf -y install --nodocs tar gzip \ + && microdnf clean all + +COPY --from=base /data/mongodb_tools_ubi.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-database/1.0.6/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-database/1.0.6/ubuntu/Dockerfile new file mode 100644 index 000000000..569ad9fb8 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-database/1.0.6/ubuntu/Dockerfile @@ -0,0 +1,30 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +ARG version +LABEL name="MongoDB Enterprise Init Database" \ + version="mongodb-enterprise-init-database-${version}" \ + summary="MongoDB Enterprise Database Init Image" \ + description="Startup Scripts for MongoDB Enterprise Database" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ + + +COPY --from=base /data/mongodb_tools_ubuntu.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-database/1.0.7/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-database/1.0.7/ubi/Dockerfile new file mode 100644 index 000000000..eb481048c --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-database/1.0.7/ubi/Dockerfile @@ -0,0 +1,34 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG version +LABEL name="MongoDB Enterprise Init Database" \ + version="mongodb-enterprise-init-database-${version}" \ + summary="MongoDB Enterprise Database Init Image" \ + description="Startup Scripts for MongoDB Enterprise Database" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ + + +RUN microdnf update --nodocs \ + && microdnf -y install --nodocs tar gzip \ + && microdnf clean all + +COPY --from=base /data/mongodb_tools_ubi.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-database/1.0.7/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-database/1.0.7/ubuntu/Dockerfile new file mode 100644 index 000000000..569ad9fb8 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-database/1.0.7/ubuntu/Dockerfile @@ -0,0 +1,30 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +ARG version +LABEL name="MongoDB Enterprise Init Database" \ + version="mongodb-enterprise-init-database-${version}" \ + summary="MongoDB Enterprise Database Init Image" \ + description="Startup Scripts for MongoDB Enterprise Database" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ + + +COPY --from=base /data/mongodb_tools_ubuntu.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-database/1.0.8/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-database/1.0.8/ubi/Dockerfile new file mode 100644 index 000000000..eb481048c --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-database/1.0.8/ubi/Dockerfile @@ -0,0 +1,34 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG version +LABEL name="MongoDB Enterprise Init Database" \ + version="mongodb-enterprise-init-database-${version}" \ + summary="MongoDB Enterprise Database Init Image" \ + description="Startup Scripts for MongoDB Enterprise Database" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ + + +RUN microdnf update --nodocs \ + && microdnf -y install --nodocs tar gzip \ + && microdnf clean all + +COPY --from=base /data/mongodb_tools_ubi.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-database/1.0.8/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-database/1.0.8/ubuntu/Dockerfile new file mode 100644 index 000000000..569ad9fb8 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-database/1.0.8/ubuntu/Dockerfile @@ -0,0 +1,30 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +ARG version +LABEL name="MongoDB Enterprise Init Database" \ + version="mongodb-enterprise-init-database-${version}" \ + summary="MongoDB Enterprise Database Init Image" \ + description="Startup Scripts for MongoDB Enterprise Database" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ + + +COPY --from=base /data/mongodb_tools_ubuntu.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-database/1.0.9/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-database/1.0.9/ubi/Dockerfile new file mode 100644 index 000000000..eb481048c --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-database/1.0.9/ubi/Dockerfile @@ -0,0 +1,34 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +ARG version +LABEL name="MongoDB Enterprise Init Database" \ + version="mongodb-enterprise-init-database-${version}" \ + summary="MongoDB Enterprise Database Init Image" \ + description="Startup Scripts for MongoDB Enterprise Database" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ + + +RUN microdnf update --nodocs \ + && microdnf -y install --nodocs tar gzip \ + && microdnf clean all + +COPY --from=base /data/mongodb_tools_ubi.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-database/1.0.9/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-database/1.0.9/ubuntu/Dockerfile new file mode 100644 index 000000000..569ad9fb8 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-database/1.0.9/ubuntu/Dockerfile @@ -0,0 +1,30 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +ARG version +LABEL name="MongoDB Enterprise Init Database" \ + version="mongodb-enterprise-init-database-${version}" \ + summary="MongoDB Enterprise Database Init Image" \ + description="Startup Scripts for MongoDB Enterprise Database" \ + release="1" \ + vendor="MongoDB" \ + maintainer="support@mongodb.com" + +COPY --from=base /data/readinessprobe /probes/readinessprobe +COPY --from=base /data/probe.sh /probes/probe.sh +COPY --from=base /data/scripts/ /scripts/ +COPY --from=base /data/licenses /licenses/ + + +COPY --from=base /data/mongodb_tools_ubuntu.tgz /tools/mongodb_tools.tgz + + +RUN tar xfz /tools/mongodb_tools.tgz --directory /tools \ + && rm /tools/mongodb_tools.tgz + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "-r", "/scripts/agent-launcher.sh", "/scripts/agent-launcher-lib.sh", "/probes/readinessprobe", "/probes/probe.sh", "/tools", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.10/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.10/ubi/Dockerfile new file mode 100644 index 000000000..0ec04fc56 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.10/ubi/Dockerfile @@ -0,0 +1,26 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +LABEL name="MongoDB Enterprise Ops Manager Init" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="mongodb-enterprise-init-ops-manager-1.0.10" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Init Image" \ + description="Startup Scripts for MongoDB Enterprise Ops Manager" + + +COPY --from=base /data/scripts /scripts +COPY --from=base /data/licenses /licenses + + +RUN microdnf update --nodocs \ + && microdnf clean all + + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "/scripts/docker-entry-point.sh", "/scripts/backup-daemon-liveness-probe.sh", "/scripts/mmsconfiguration", "/scripts/backup-daemon-readiness-probe", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.10/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.10/ubuntu/Dockerfile new file mode 100644 index 000000000..861f778fb --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.10/ubuntu/Dockerfile @@ -0,0 +1,24 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +LABEL name="MongoDB Enterprise Ops Manager Init" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="mongodb-enterprise-init-ops-manager-1.0.10" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Init Image" \ + description="Startup Scripts for MongoDB Enterprise Ops Manager" + + +COPY --from=base /data/scripts /scripts +COPY --from=base /data/licenses /licenses + + + + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "/scripts/docker-entry-point.sh", "/scripts/backup-daemon-liveness-probe.sh", "/scripts/mmsconfiguration", "/scripts/backup-daemon-readiness-probe", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.3/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.3/ubi/Dockerfile new file mode 100644 index 000000000..20ce05fb8 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.3/ubi/Dockerfile @@ -0,0 +1,21 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +LABEL name="MongoDB Enterprise Ops Manager Init" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="mongodb-enterprise-init-ops-manager-1.0.3" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Init Image" \ + description="Startup Scripts for MongoDB Enterprise Ops Manager" + + +COPY --from=base /data/scripts /scripts +COPY --from=base /data/licenses /licenses + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "/scripts/docker-entry-point.sh", "/scripts/mmsconfiguration", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.3/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.3/ubuntu/Dockerfile new file mode 100644 index 000000000..76042229d --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.3/ubuntu/Dockerfile @@ -0,0 +1,21 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +LABEL name="MongoDB Enterprise Ops Manager Init" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="mongodb-enterprise-init-ops-manager-1.0.3" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Init Image" \ + description="Startup Scripts for MongoDB Enterprise Ops Manager" + + +COPY --from=base /data/scripts /scripts +COPY --from=base /data/licenses /licenses + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "/scripts/docker-entry-point.sh", "/scripts/mmsconfiguration", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.4/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.4/ubi/Dockerfile new file mode 100644 index 000000000..44951ba71 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.4/ubi/Dockerfile @@ -0,0 +1,26 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +LABEL name="MongoDB Enterprise Ops Manager Init" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="mongodb-enterprise-init-ops-manager-1.0.4" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Init Image" \ + description="Startup Scripts for MongoDB Enterprise Ops Manager" + + +COPY --from=base /data/scripts /scripts +COPY --from=base /data/licenses /licenses + + +RUN microdnf update --nodocs \ + && microdnf clean all + + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "/scripts/docker-entry-point.sh", "/scripts/backup-daemon-liveness-probe.sh", "/scripts/mmsconfiguration", "/scripts/backup-daemon-readiness-probe", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.4/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.4/ubuntu/Dockerfile new file mode 100644 index 000000000..5c0420843 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.4/ubuntu/Dockerfile @@ -0,0 +1,24 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +LABEL name="MongoDB Enterprise Ops Manager Init" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="mongodb-enterprise-init-ops-manager-1.0.4" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Init Image" \ + description="Startup Scripts for MongoDB Enterprise Ops Manager" + + +COPY --from=base /data/scripts /scripts +COPY --from=base /data/licenses /licenses + + + + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "/scripts/docker-entry-point.sh", "/scripts/backup-daemon-liveness-probe.sh", "/scripts/mmsconfiguration", "/scripts/backup-daemon-readiness-probe", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.5/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.5/ubi/Dockerfile new file mode 100644 index 000000000..4a4026e5c --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.5/ubi/Dockerfile @@ -0,0 +1,26 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +LABEL name="MongoDB Enterprise Ops Manager Init" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="mongodb-enterprise-init-ops-manager-1.0.5" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Init Image" \ + description="Startup Scripts for MongoDB Enterprise Ops Manager" + + +COPY --from=base /data/scripts /scripts +COPY --from=base /data/licenses /licenses + + +RUN microdnf update --nodocs \ + && microdnf clean all + + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "/scripts/docker-entry-point.sh", "/scripts/backup-daemon-liveness-probe.sh", "/scripts/mmsconfiguration", "/scripts/backup-daemon-readiness-probe", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.5/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.5/ubuntu/Dockerfile new file mode 100644 index 000000000..d2f704bac --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.5/ubuntu/Dockerfile @@ -0,0 +1,24 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +LABEL name="MongoDB Enterprise Ops Manager Init" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="mongodb-enterprise-init-ops-manager-1.0.5" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Init Image" \ + description="Startup Scripts for MongoDB Enterprise Ops Manager" + + +COPY --from=base /data/scripts /scripts +COPY --from=base /data/licenses /licenses + + + + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "/scripts/docker-entry-point.sh", "/scripts/backup-daemon-liveness-probe.sh", "/scripts/mmsconfiguration", "/scripts/backup-daemon-readiness-probe", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.6/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.6/ubi/Dockerfile new file mode 100644 index 000000000..22a7ae733 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.6/ubi/Dockerfile @@ -0,0 +1,26 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +LABEL name="MongoDB Enterprise Ops Manager Init" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="mongodb-enterprise-init-ops-manager-1.0.6" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Init Image" \ + description="Startup Scripts for MongoDB Enterprise Ops Manager" + + +COPY --from=base /data/scripts /scripts +COPY --from=base /data/licenses /licenses + + +RUN microdnf update --nodocs \ + && microdnf clean all + + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "/scripts/docker-entry-point.sh", "/scripts/backup-daemon-liveness-probe.sh", "/scripts/mmsconfiguration", "/scripts/backup-daemon-readiness-probe", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.6/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.6/ubuntu/Dockerfile new file mode 100644 index 000000000..f39bd5abd --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.6/ubuntu/Dockerfile @@ -0,0 +1,24 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +LABEL name="MongoDB Enterprise Ops Manager Init" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="mongodb-enterprise-init-ops-manager-1.0.6" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Init Image" \ + description="Startup Scripts for MongoDB Enterprise Ops Manager" + + +COPY --from=base /data/scripts /scripts +COPY --from=base /data/licenses /licenses + + + + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "/scripts/docker-entry-point.sh", "/scripts/backup-daemon-liveness-probe.sh", "/scripts/mmsconfiguration", "/scripts/backup-daemon-readiness-probe", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.7/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.7/ubi/Dockerfile new file mode 100644 index 000000000..bb3c9d353 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.7/ubi/Dockerfile @@ -0,0 +1,26 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +LABEL name="MongoDB Enterprise Ops Manager Init" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="mongodb-enterprise-init-ops-manager-1.0.7" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Init Image" \ + description="Startup Scripts for MongoDB Enterprise Ops Manager" + + +COPY --from=base /data/scripts /scripts +COPY --from=base /data/licenses /licenses + + +RUN microdnf update --nodocs \ + && microdnf clean all + + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "/scripts/docker-entry-point.sh", "/scripts/backup-daemon-liveness-probe.sh", "/scripts/mmsconfiguration", "/scripts/backup-daemon-readiness-probe", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.7/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.7/ubuntu/Dockerfile new file mode 100644 index 000000000..20dd9d563 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.7/ubuntu/Dockerfile @@ -0,0 +1,24 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +LABEL name="MongoDB Enterprise Ops Manager Init" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="mongodb-enterprise-init-ops-manager-1.0.7" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Init Image" \ + description="Startup Scripts for MongoDB Enterprise Ops Manager" + + +COPY --from=base /data/scripts /scripts +COPY --from=base /data/licenses /licenses + + + + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "/scripts/docker-entry-point.sh", "/scripts/backup-daemon-liveness-probe.sh", "/scripts/mmsconfiguration", "/scripts/backup-daemon-readiness-probe", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.8/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.8/ubi/Dockerfile new file mode 100644 index 000000000..e2076d949 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.8/ubi/Dockerfile @@ -0,0 +1,26 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +LABEL name="MongoDB Enterprise Ops Manager Init" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="mongodb-enterprise-init-ops-manager-1.0.8" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Init Image" \ + description="Startup Scripts for MongoDB Enterprise Ops Manager" + + +COPY --from=base /data/scripts /scripts +COPY --from=base /data/licenses /licenses + + +RUN microdnf update --nodocs \ + && microdnf clean all + + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "/scripts/docker-entry-point.sh", "/scripts/backup-daemon-liveness-probe.sh", "/scripts/mmsconfiguration", "/scripts/backup-daemon-readiness-probe", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.8/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.8/ubuntu/Dockerfile new file mode 100644 index 000000000..a7aef7e0a --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.8/ubuntu/Dockerfile @@ -0,0 +1,24 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +LABEL name="MongoDB Enterprise Ops Manager Init" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="mongodb-enterprise-init-ops-manager-1.0.8" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Init Image" \ + description="Startup Scripts for MongoDB Enterprise Ops Manager" + + +COPY --from=base /data/scripts /scripts +COPY --from=base /data/licenses /licenses + + + + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "/scripts/docker-entry-point.sh", "/scripts/backup-daemon-liveness-probe.sh", "/scripts/mmsconfiguration", "/scripts/backup-daemon-readiness-probe", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.9/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.9/ubi/Dockerfile new file mode 100644 index 000000000..930e62cb4 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.9/ubi/Dockerfile @@ -0,0 +1,26 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + +LABEL name="MongoDB Enterprise Ops Manager Init" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="mongodb-enterprise-init-ops-manager-1.0.9" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Init Image" \ + description="Startup Scripts for MongoDB Enterprise Ops Manager" + + +COPY --from=base /data/scripts /scripts +COPY --from=base /data/licenses /licenses + + +RUN microdnf update --nodocs \ + && microdnf clean all + + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "/scripts/docker-entry-point.sh", "/scripts/backup-daemon-liveness-probe.sh", "/scripts/mmsconfiguration", "/scripts/backup-daemon-readiness-probe", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.9/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.9/ubuntu/Dockerfile new file mode 100644 index 000000000..e2d455f1e --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-init-ops-manager/1.0.9/ubuntu/Dockerfile @@ -0,0 +1,24 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM busybox + +LABEL name="MongoDB Enterprise Ops Manager Init" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="mongodb-enterprise-init-ops-manager-1.0.9" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Init Image" \ + description="Startup Scripts for MongoDB Enterprise Ops Manager" + + +COPY --from=base /data/scripts /scripts +COPY --from=base /data/licenses /licenses + + + + +USER 2000 +ENTRYPOINT [ "/bin/cp", "-f", "/scripts/docker-entry-point.sh", "/scripts/backup-daemon-liveness-probe.sh", "/scripts/mmsconfiguration", "/scripts/backup-daemon-readiness-probe", "/opt/scripts/" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.10.0/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.10.0/ubi/Dockerfile new file mode 100644 index 000000000..77e2c3c48 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.10.0/ubi/Dockerfile @@ -0,0 +1,40 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.10.0" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + +# Building an UBI-based image: https://red.ht/3n6b9y0 +RUN yum update \ + --disableplugin=subscription-manager \ + --disablerepo=* --enablerepo=ubi-8-appstream --enablerepo=ubi-8-baseos -y \ + && rm -rf /var/cache/yum + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/version_manifest.json /var/lib/mongodb-enterprise-operator/version_manifest.json +COPY --from=base /data/licenses /licenses/ +RUN chmod a+r /var/lib/mongodb-enterprise-operator/version_manifest.json + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.10.0/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.10.0/ubuntu/Dockerfile new file mode 100644 index 000000000..47e0a516e --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.10.0/ubuntu/Dockerfile @@ -0,0 +1,42 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:xenial-20210114 + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.10.0" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + + +# Adds up-to-date CA certificates. +RUN apt-get -qq update && \ + apt-get -y -qq install ca-certificates curl && \ + apt-get upgrade -y -qq && \ + apt-get dist-upgrade -y -qq && \ + rm -rf /var/lib/apt/lists/* + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/version_manifest.json /var/lib/mongodb-enterprise-operator/version_manifest.json +COPY --from=base /data/licenses /licenses/ +RUN chmod a+r /var/lib/mongodb-enterprise-operator/version_manifest.json + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.11.0/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.11.0/ubi/Dockerfile new file mode 100644 index 000000000..797927ade --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.11.0/ubi/Dockerfile @@ -0,0 +1,39 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.11.0" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + +# Building an UBI-based image: https://red.ht/3n6b9y0 +RUN yum update \ + --disableplugin=subscription-manager \ + --disablerepo=* --enablerepo=ubi-8-appstream --enablerepo=ubi-8-baseos -y \ + && rm -rf /var/cache/yum + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.11.0/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.11.0/ubuntu/Dockerfile new file mode 100644 index 000000000..47ece25e5 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.11.0/ubuntu/Dockerfile @@ -0,0 +1,41 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:16.04 + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.11.0" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + + +# Adds up-to-date CA certificates. +RUN apt-get -qq update && \ + apt-get -y -qq install ca-certificates curl && \ + apt-get upgrade -y -qq && \ + apt-get dist-upgrade -y -qq && \ + rm -rf /var/lib/apt/lists/* + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.12.0/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.12.0/ubi/Dockerfile new file mode 100644 index 000000000..d55ac6651 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.12.0/ubi/Dockerfile @@ -0,0 +1,39 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.12.0" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + +# Building an UBI-based image: https://red.ht/3n6b9y0 +RUN microdnf update \ + --disableplugin=subscription-manager \ + --disablerepo=* --enablerepo=ubi-8-appstream --enablerepo=ubi-8-baseos -y \ + && rm -rf /var/cache/yum + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.12.0/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.12.0/ubuntu/Dockerfile new file mode 100644 index 000000000..f8d171148 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.12.0/ubuntu/Dockerfile @@ -0,0 +1,41 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.12.0" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + + +# Adds up-to-date CA certificates. +RUN apt-get -qq update && \ + apt-get -y -qq install ca-certificates curl && \ + apt-get upgrade -y -qq && \ + apt-get dist-upgrade -y -qq && \ + rm -rf /var/lib/apt/lists/* + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.13.0/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.13.0/ubi/Dockerfile new file mode 100644 index 000000000..96bbfe262 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.13.0/ubi/Dockerfile @@ -0,0 +1,39 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.13.0" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + +# Building an UBI-based image: https://red.ht/3n6b9y0 +RUN microdnf update \ + --disableplugin=subscription-manager \ + --disablerepo=* --enablerepo=ubi-8-appstream --enablerepo=ubi-8-baseos -y \ + && rm -rf /var/cache/yum + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.13.0/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.13.0/ubuntu/Dockerfile new file mode 100644 index 000000000..aa89ac8b1 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.13.0/ubuntu/Dockerfile @@ -0,0 +1,41 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.13.0" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + + +# Adds up-to-date CA certificates. +RUN apt-get -qq update && \ + apt-get -y -qq install ca-certificates curl && \ + apt-get upgrade -y -qq && \ + apt-get dist-upgrade -y -qq && \ + rm -rf /var/lib/apt/lists/* + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.14.0/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.14.0/ubi/Dockerfile new file mode 100644 index 000000000..3c098710a --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.14.0/ubi/Dockerfile @@ -0,0 +1,39 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.14.0" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + +# Building an UBI-based image: https://red.ht/3n6b9y0 +RUN microdnf update \ + --disableplugin=subscription-manager \ + --disablerepo=* --enablerepo=ubi-8-appstream --enablerepo=ubi-8-baseos -y \ + && rm -rf /var/cache/yum + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.14.0/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.14.0/ubuntu/Dockerfile new file mode 100644 index 000000000..09a6f3822 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.14.0/ubuntu/Dockerfile @@ -0,0 +1,41 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.14.0" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + + +# Adds up-to-date CA certificates. +RUN apt-get -qq update && \ + apt-get -y -qq install ca-certificates curl && \ + apt-get upgrade -y -qq && \ + apt-get dist-upgrade -y -qq && \ + rm -rf /var/lib/apt/lists/* + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.15.0/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.15.0/ubi/Dockerfile new file mode 100644 index 000000000..0ef348ed4 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.15.0/ubi/Dockerfile @@ -0,0 +1,39 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.15.0" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + +# Building an UBI-based image: https://red.ht/3n6b9y0 +RUN microdnf update \ + --disableplugin=subscription-manager \ + --disablerepo=* --enablerepo=ubi-8-appstream --enablerepo=ubi-8-baseos -y \ + && rm -rf /var/cache/yum + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.15.0/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.15.0/ubuntu/Dockerfile new file mode 100644 index 000000000..315cd01ce --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.15.0/ubuntu/Dockerfile @@ -0,0 +1,41 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.15.0" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + + +# Adds up-to-date CA certificates. +RUN apt-get -qq update && \ + apt-get -y -qq install ca-certificates curl && \ + apt-get upgrade -y -qq && \ + apt-get dist-upgrade -y -qq && \ + rm -rf /var/lib/apt/lists/* + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.15.1/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.15.1/ubi/Dockerfile new file mode 100644 index 000000000..2fe9a7b00 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.15.1/ubi/Dockerfile @@ -0,0 +1,39 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.15.1" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + +# Building an UBI-based image: https://red.ht/3n6b9y0 +RUN microdnf update \ + --disableplugin=subscription-manager \ + --disablerepo=* --enablerepo=ubi-8-appstream --enablerepo=ubi-8-baseos -y \ + && rm -rf /var/cache/yum + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.15.1/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.15.1/ubuntu/Dockerfile new file mode 100644 index 000000000..e8ee4088b --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.15.1/ubuntu/Dockerfile @@ -0,0 +1,41 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.15.1" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + + +# Adds up-to-date CA certificates. +RUN apt-get -qq update && \ + apt-get -y -qq install ca-certificates curl && \ + apt-get upgrade -y -qq && \ + apt-get dist-upgrade -y -qq && \ + rm -rf /var/lib/apt/lists/* + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.15.2/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.15.2/ubi/Dockerfile new file mode 100644 index 000000000..c171bb4c6 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.15.2/ubi/Dockerfile @@ -0,0 +1,39 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.15.2" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + +# Building an UBI-based image: https://red.ht/3n6b9y0 +RUN microdnf update \ + --disableplugin=subscription-manager \ + --disablerepo=* --enablerepo=ubi-8-appstream --enablerepo=ubi-8-baseos -y \ + && rm -rf /var/cache/yum + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.15.2/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.15.2/ubuntu/Dockerfile new file mode 100644 index 000000000..51b37e27a --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.15.2/ubuntu/Dockerfile @@ -0,0 +1,41 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.15.2" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + + +# Adds up-to-date CA certificates. +RUN apt-get -qq update && \ + apt-get -y -qq install ca-certificates curl && \ + apt-get upgrade -y -qq && \ + apt-get dist-upgrade -y -qq && \ + rm -rf /var/lib/apt/lists/* + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.16.0/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.16.0/ubi/Dockerfile new file mode 100644 index 000000000..0a34ada36 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.16.0/ubi/Dockerfile @@ -0,0 +1,39 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.16.0" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + +# Building an UBI-based image: https://red.ht/3n6b9y0 +RUN microdnf update \ + --disableplugin=subscription-manager \ + --disablerepo=* --enablerepo=ubi-8-appstream --enablerepo=ubi-8-baseos -y \ + && rm -rf /var/cache/yum + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.16.0/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.16.0/ubuntu/Dockerfile new file mode 100644 index 000000000..f239c57fd --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.16.0/ubuntu/Dockerfile @@ -0,0 +1,41 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.16.0" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + + +# Adds up-to-date CA certificates. +RUN apt-get -qq update && \ + apt-get -y -qq install ca-certificates curl && \ + apt-get upgrade -y -qq && \ + apt-get dist-upgrade -y -qq && \ + rm -rf /var/lib/apt/lists/* + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.16.1/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.16.1/ubi/Dockerfile new file mode 100644 index 000000000..5b9ee44e6 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.16.1/ubi/Dockerfile @@ -0,0 +1,39 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.16.1" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + +# Building an UBI-based image: https://red.ht/3n6b9y0 +RUN microdnf update \ + --disableplugin=subscription-manager \ + --disablerepo=* --enablerepo=ubi-8-appstream --enablerepo=ubi-8-baseos -y \ + && rm -rf /var/cache/yum + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.16.1/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.16.1/ubuntu/Dockerfile new file mode 100644 index 000000000..df19ca3df --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.16.1/ubuntu/Dockerfile @@ -0,0 +1,41 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.16.1" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + + +# Adds up-to-date CA certificates. +RUN apt-get -qq update && \ + apt-get -y -qq install ca-certificates curl && \ + apt-get upgrade -y -qq && \ + apt-get dist-upgrade -y -qq && \ + rm -rf /var/lib/apt/lists/* + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.16.2/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.16.2/ubi/Dockerfile new file mode 100644 index 000000000..948d751ce --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.16.2/ubi/Dockerfile @@ -0,0 +1,39 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.16.2" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + +# Building an UBI-based image: https://red.ht/3n6b9y0 +RUN microdnf update \ + --disableplugin=subscription-manager \ + --disablerepo=* --enablerepo=ubi-8-appstream --enablerepo=ubi-8-baseos -y \ + && rm -rf /var/cache/yum + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.16.2/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.16.2/ubuntu/Dockerfile new file mode 100644 index 000000000..70ff9fa35 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.16.2/ubuntu/Dockerfile @@ -0,0 +1,41 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.16.2" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + + +# Adds up-to-date CA certificates. +RUN apt-get -qq update && \ + apt-get -y -qq install ca-certificates curl && \ + apt-get upgrade -y -qq && \ + apt-get dist-upgrade -y -qq && \ + rm -rf /var/lib/apt/lists/* + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.16.3/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.16.3/ubi/Dockerfile new file mode 100644 index 000000000..00601c319 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.16.3/ubi/Dockerfile @@ -0,0 +1,39 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.16.3" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + +# Building an UBI-based image: https://red.ht/3n6b9y0 +RUN microdnf update \ + --disableplugin=subscription-manager \ + --disablerepo=* --enablerepo=ubi-8-appstream --enablerepo=ubi-8-baseos -y \ + && rm -rf /var/cache/yum + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.16.3/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.16.3/ubuntu/Dockerfile new file mode 100644 index 000000000..a4e0f4b37 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.16.3/ubuntu/Dockerfile @@ -0,0 +1,41 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.16.3" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + + +# Adds up-to-date CA certificates. +RUN apt-get -qq update && \ + apt-get -y -qq install ca-certificates curl && \ + apt-get upgrade -y -qq && \ + apt-get dist-upgrade -y -qq && \ + rm -rf /var/lib/apt/lists/* + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.16.4/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.16.4/ubi/Dockerfile new file mode 100644 index 000000000..1fcab6ce6 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.16.4/ubi/Dockerfile @@ -0,0 +1,39 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.16.4" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + +# Building an UBI-based image: https://red.ht/3n6b9y0 +RUN microdnf update \ + --disableplugin=subscription-manager \ + --disablerepo=* --enablerepo=ubi-8-appstream --enablerepo=ubi-8-baseos -y \ + && rm -rf /var/cache/yum + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.16.4/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.16.4/ubuntu/Dockerfile new file mode 100644 index 000000000..96c3b8d73 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.16.4/ubuntu/Dockerfile @@ -0,0 +1,41 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.16.4" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + + +# Adds up-to-date CA certificates. +RUN apt-get -qq update && \ + apt-get -y -qq install ca-certificates curl && \ + apt-get upgrade -y -qq && \ + apt-get dist-upgrade -y -qq && \ + rm -rf /var/lib/apt/lists/* + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.17.0/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.17.0/ubi/Dockerfile new file mode 100644 index 000000000..4f86a9a8b --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.17.0/ubi/Dockerfile @@ -0,0 +1,39 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.17.0" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + +# Building an UBI-based image: https://red.ht/3n6b9y0 +RUN microdnf update \ + --disableplugin=subscription-manager \ + --disablerepo=* --enablerepo=ubi-8-appstream-rpms --enablerepo=ubi-8-baseos-rpms -y \ + && rm -rf /var/cache/yum + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.17.0/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.17.0/ubuntu/Dockerfile new file mode 100644 index 000000000..6d68273cf --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.17.0/ubuntu/Dockerfile @@ -0,0 +1,41 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.17.0" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + + +# Adds up-to-date CA certificates. +RUN apt-get -qq update && \ + apt-get -y -qq install ca-certificates curl && \ + apt-get upgrade -y -qq && \ + apt-get dist-upgrade -y -qq && \ + rm -rf /var/lib/apt/lists/* + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.17.1/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.17.1/ubi/Dockerfile new file mode 100644 index 000000000..aba3e6038 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.17.1/ubi/Dockerfile @@ -0,0 +1,39 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.17.1" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + +# Building an UBI-based image: https://red.ht/3n6b9y0 +RUN microdnf update \ + --disableplugin=subscription-manager \ + --disablerepo=* --enablerepo=ubi-8-appstream-rpms --enablerepo=ubi-8-baseos-rpms -y \ + && rm -rf /var/cache/yum + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.17.1/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.17.1/ubuntu/Dockerfile new file mode 100644 index 000000000..8823e95f5 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.17.1/ubuntu/Dockerfile @@ -0,0 +1,41 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.17.1" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + + +# Adds up-to-date CA certificates. +RUN apt-get -qq update && \ + apt-get -y -qq install ca-certificates curl && \ + apt-get upgrade -y -qq && \ + apt-get dist-upgrade -y -qq && \ + rm -rf /var/lib/apt/lists/* + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.17.2/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.17.2/ubi/Dockerfile new file mode 100644 index 000000000..99e6c3f3b --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.17.2/ubi/Dockerfile @@ -0,0 +1,39 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.17.2" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + +# Building an UBI-based image: https://red.ht/3n6b9y0 +RUN microdnf update \ + --disableplugin=subscription-manager \ + --disablerepo=* --enablerepo=ubi-8-appstream-rpms --enablerepo=ubi-8-baseos-rpms -y \ + && rm -rf /var/cache/yum + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.17.2/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.17.2/ubuntu/Dockerfile new file mode 100644 index 000000000..7a2903648 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.17.2/ubuntu/Dockerfile @@ -0,0 +1,41 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.17.2" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + + +# Adds up-to-date CA certificates. +RUN apt-get -qq update && \ + apt-get -y -qq install ca-certificates curl && \ + apt-get upgrade -y -qq && \ + apt-get dist-upgrade -y -qq && \ + rm -rf /var/lib/apt/lists/* + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.18.0/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.18.0/ubi/Dockerfile new file mode 100644 index 000000000..96ff34de1 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.18.0/ubi/Dockerfile @@ -0,0 +1,39 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.18.0" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + +# Building an UBI-based image: https://red.ht/3n6b9y0 +RUN microdnf update \ + --disableplugin=subscription-manager \ + --disablerepo=* --enablerepo=ubi-8-appstream-rpms --enablerepo=ubi-8-baseos-rpms -y \ + && rm -rf /var/cache/yum + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.18.0/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.18.0/ubuntu/Dockerfile new file mode 100644 index 000000000..66a24d16e --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.18.0/ubuntu/Dockerfile @@ -0,0 +1,41 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.18.0" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + + +# Adds up-to-date CA certificates. +RUN apt-get -qq update && \ + apt-get -y -qq install ca-certificates curl && \ + apt-get upgrade -y -qq && \ + apt-get dist-upgrade -y -qq && \ + rm -rf /var/lib/apt/lists/* + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/om_version_mapping.json /usr/local/om_version_mapping.json +COPY --from=base /data/licenses /licenses/ + +USER 2000 + + + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.9.0/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.9.0/ubi/Dockerfile new file mode 100644 index 000000000..87e7328ff --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.9.0/ubi/Dockerfile @@ -0,0 +1,35 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.9.0-23-g9050b474" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + +RUN yum -y --disableplugin=subscription-manager update && \ + yum -y --disableplugin=subscription-manager clean all + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/version_manifest.json /var/lib/mongodb-enterprise-operator/version_manifest.json +COPY --from=base /data/licenses /licenses/ +RUN chmod a+r /var/lib/mongodb-enterprise-operator/version_manifest.json + +USER 2000 + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.9.0/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.9.0/ubuntu/Dockerfile new file mode 100644 index 000000000..6761f5d69 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.9.0/ubuntu/Dockerfile @@ -0,0 +1,40 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:xenial-20210416 + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.9.0" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + + +# Adds up-to-date CA certificates. +RUN apt-get -qq update && \ + apt-get -y -qq install ca-certificates curl && \ + apt-get upgrade -y -qq && \ + apt-get dist-upgrade -y -qq && \ + rm -rf /var/lib/apt/lists/* + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/version_manifest.json /var/lib/mongodb-enterprise-operator/version_manifest.json +COPY --from=base /data/licenses /licenses/ +RUN chmod a+r /var/lib/mongodb-enterprise-operator/version_manifest.json + +USER 2000 + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.9.1/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.9.1/ubi/Dockerfile new file mode 100644 index 000000000..2f0e3a091 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.9.1/ubi/Dockerfile @@ -0,0 +1,38 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.9.1" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + +# Building an UBI-based image: https://red.ht/3n6b9y0 +RUN yum update \ + --disableplugin=subscription-manager \ + --disablerepo=* --enablerepo=ubi-8-appstream-rpms --enablerepo=ubi-8-baseos-rpms -y \ + && rm -rf /var/cache/yum + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/version_manifest.json /var/lib/mongodb-enterprise-operator/version_manifest.json +COPY --from=base /data/licenses /licenses/ +RUN chmod a+r /var/lib/mongodb-enterprise-operator/version_manifest.json + +USER 2000 + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.9.1/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.9.1/ubuntu/Dockerfile new file mode 100644 index 000000000..091afe62c --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.9.1/ubuntu/Dockerfile @@ -0,0 +1,40 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:xenial-20210416 + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.9.1" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + + +# Adds up-to-date CA certificates. +RUN apt-get -qq update && \ + apt-get -y -qq install ca-certificates curl && \ + apt-get upgrade -y -qq && \ + apt-get dist-upgrade -y -qq && \ + rm -rf /var/lib/apt/lists/* + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/version_manifest.json /var/lib/mongodb-enterprise-operator/version_manifest.json +COPY --from=base /data/licenses /licenses/ +RUN chmod a+r /var/lib/mongodb-enterprise-operator/version_manifest.json + +USER 2000 + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.9.2/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.9.2/ubi/Dockerfile new file mode 100644 index 000000000..673dd120e --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.9.2/ubi/Dockerfile @@ -0,0 +1,38 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.9.2" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + +# Building an UBI-based image: https://red.ht/3n6b9y0 +RUN yum update \ + --disableplugin=subscription-manager \ + --disablerepo=* --enablerepo=ubi-8-appstream-rpms --enablerepo=ubi-8-baseos-rpms -y \ + && rm -rf /var/cache/yum + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/version_manifest.json /var/lib/mongodb-enterprise-operator/version_manifest.json +COPY --from=base /data/licenses /licenses/ +RUN chmod a+r /var/lib/mongodb-enterprise-operator/version_manifest.json + +USER 2000 + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-operator/1.9.2/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-operator/1.9.2/ubuntu/Dockerfile new file mode 100644 index 000000000..0a9b431ad --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-operator/1.9.2/ubuntu/Dockerfile @@ -0,0 +1,40 @@ +# +# Base Template Dockerfile for Operator Image. +# + +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:xenial-20210416 + + +LABEL name="MongoDB Enterprise Operator" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="1.9.2" \ + release="1" \ + summary="MongoDB Enterprise Operator Image" \ + description="MongoDB Enterprise Operator Image" + + + +# Adds up-to-date CA certificates. +RUN apt-get -qq update && \ + apt-get -y -qq install ca-certificates curl && \ + apt-get upgrade -y -qq && \ + apt-get dist-upgrade -y -qq && \ + rm -rf /var/lib/apt/lists/* + + + + +COPY --from=base /data/mongodb-enterprise-operator /usr/local/bin/mongodb-enterprise-operator +COPY --from=base /data/version_manifest.json /var/lib/mongodb-enterprise-operator/version_manifest.json +COPY --from=base /data/licenses /licenses/ +RUN chmod a+r /var/lib/mongodb-enterprise-operator/version_manifest.json + +USER 2000 + +ENTRYPOINT exec /usr/local/bin/mongodb-enterprise-operator + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.2.26/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.2.26/ubi/Dockerfile new file mode 100644 index 000000000..4e98fb9fb --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.2.26/ubi/Dockerfile @@ -0,0 +1,70 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.2.26" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN yum install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.2.26.57139.20210727T2031Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.2.26/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.2.26/ubuntu/Dockerfile new file mode 100644 index 000000000..036aad8fa --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.2.26/ubuntu/Dockerfile @@ -0,0 +1,79 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.2.26" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.2.26.57139.20210727T2031Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.10/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.10/ubi/Dockerfile new file mode 100644 index 000000000..989268949 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.10/ubi/Dockerfile @@ -0,0 +1,71 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.10" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.10.100.20210303T2102Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.10/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.10/ubuntu/Dockerfile new file mode 100644 index 000000000..2d9924a6b --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.10/ubuntu/Dockerfile @@ -0,0 +1,79 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.10" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.10.100.20210303T2102Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.11/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.11/ubi/Dockerfile new file mode 100644 index 000000000..2fc4b324a --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.11/ubi/Dockerfile @@ -0,0 +1,71 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.11" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.11.100.20210330T2227Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.11/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.11/ubuntu/Dockerfile new file mode 100644 index 000000000..53ef443f0 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.11/ubuntu/Dockerfile @@ -0,0 +1,79 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.11" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.11.100.20210330T2227Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.12/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.12/ubi/Dockerfile new file mode 100644 index 000000000..708a9c4dc --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.12/ubi/Dockerfile @@ -0,0 +1,71 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.12" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.12.100.20210503T1412Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.12/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.12/ubuntu/Dockerfile new file mode 100644 index 000000000..36e5ea948 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.12/ubuntu/Dockerfile @@ -0,0 +1,79 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.12" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.12.100.20210503T1412Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.13/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.13/ubi/Dockerfile new file mode 100644 index 000000000..5809d9753 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.13/ubi/Dockerfile @@ -0,0 +1,71 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.13" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.13.100.20210603T0055Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.13/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.13/ubuntu/Dockerfile new file mode 100644 index 000000000..11c4683fa --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.13/ubuntu/Dockerfile @@ -0,0 +1,79 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.13" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.13.100.20210603T0055Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.14/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.14/ubi/Dockerfile new file mode 100644 index 000000000..c0a565894 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.14/ubi/Dockerfile @@ -0,0 +1,70 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.14" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN yum install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.14.100.20210610T1501Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.14/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.14/ubuntu/Dockerfile new file mode 100644 index 000000000..00323ec88 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.14/ubuntu/Dockerfile @@ -0,0 +1,79 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.14" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.14.100.20210610T1501Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.15/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.15/ubi/Dockerfile new file mode 100644 index 000000000..0aaf7565d --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.15/ubi/Dockerfile @@ -0,0 +1,71 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.15" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.15.99.20210629T2013Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.15/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.15/ubuntu/Dockerfile new file mode 100644 index 000000000..b25b4d4a3 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.15/ubuntu/Dockerfile @@ -0,0 +1,79 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.15" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.15.99.20210629T2013Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.16/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.16/ubi/Dockerfile new file mode 100644 index 000000000..dc658a634 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.16/ubi/Dockerfile @@ -0,0 +1,71 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.16" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.16.100.20210804T2025Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.16/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.16/ubuntu/Dockerfile new file mode 100644 index 000000000..ba0c9e2e9 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.16/ubuntu/Dockerfile @@ -0,0 +1,79 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.16" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.16.100.20210804T2025Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.17/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.17/ubi/Dockerfile new file mode 100644 index 000000000..8e3bac4b4 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.17/ubi/Dockerfile @@ -0,0 +1,71 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.17" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.17.100.20210901T1617Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.17/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.17/ubuntu/Dockerfile new file mode 100644 index 000000000..65aeb5cf3 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.17/ubuntu/Dockerfile @@ -0,0 +1,79 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.17" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.17.100.20210901T1617Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.18/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.18/ubi/Dockerfile new file mode 100644 index 000000000..cb819f4e6 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.18/ubi/Dockerfile @@ -0,0 +1,71 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.18" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.18.100.20211103T1205Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.18/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.18/ubuntu/Dockerfile new file mode 100644 index 000000000..5fc373ff6 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.18/ubuntu/Dockerfile @@ -0,0 +1,79 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.18" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.18.100.20211103T1205Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.19/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.19/ubi/Dockerfile new file mode 100644 index 000000000..c7e6e12d0 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.19/ubi/Dockerfile @@ -0,0 +1,71 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.19" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.19.100.20211116T1357Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.19/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.19/ubuntu/Dockerfile new file mode 100644 index 000000000..e96910be3 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.19/ubuntu/Dockerfile @@ -0,0 +1,79 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.19" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.19.100.20211116T1357Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.20/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.20/ubi/Dockerfile new file mode 100644 index 000000000..7017a78d4 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.20/ubi/Dockerfile @@ -0,0 +1,72 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.20" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.20.100.20220110T2138Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.20/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.20/ubuntu/Dockerfile new file mode 100644 index 000000000..45439cd32 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.20/ubuntu/Dockerfile @@ -0,0 +1,80 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.20" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.20.100.20220110T2138Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.21/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.21/ubi/Dockerfile new file mode 100644 index 000000000..d0ce73afe --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.21/ubi/Dockerfile @@ -0,0 +1,72 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.21" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.21.100.20220217T0528Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.21/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.21/ubuntu/Dockerfile new file mode 100644 index 000000000..c423bcc7a --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.21/ubuntu/Dockerfile @@ -0,0 +1,80 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.21" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.21.100.20220217T0528Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.22/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.22/ubi/Dockerfile new file mode 100644 index 000000000..cd96e410d --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.22/ubi/Dockerfile @@ -0,0 +1,72 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.22" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.22.100.20220504T1105Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.22/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.22/ubuntu/Dockerfile new file mode 100644 index 000000000..370f492ef --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.22/ubuntu/Dockerfile @@ -0,0 +1,80 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.22" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.22.100.20220504T1105Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.23/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.23/ubi/Dockerfile new file mode 100644 index 000000000..a5361ae54 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.23/ubi/Dockerfile @@ -0,0 +1,75 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.23" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.23.100.20220706T0858Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.23/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.23/ubuntu/Dockerfile new file mode 100644 index 000000000..1b902ebac --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.23/ubuntu/Dockerfile @@ -0,0 +1,83 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.23" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.23.100.20220706T0858Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.24/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.24/ubi/Dockerfile new file mode 100644 index 000000000..0af1aad32 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.24/ubi/Dockerfile @@ -0,0 +1,75 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.24" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.24.100.20220728T1823Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.24/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.24/ubuntu/Dockerfile new file mode 100644 index 000000000..8f826ba72 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.24/ubuntu/Dockerfile @@ -0,0 +1,83 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.24" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.24.100.20220728T1823Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.7/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.7/ubi/Dockerfile new file mode 100644 index 000000000..2b7dc26b1 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.7/ubi/Dockerfile @@ -0,0 +1,71 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.7" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.7.100.20210109T1656Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.7/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.7/ubuntu/Dockerfile new file mode 100644 index 000000000..5a0df273e --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.7/ubuntu/Dockerfile @@ -0,0 +1,79 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.7" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.7.100.20210109T1656Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.9/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.9/ubi/Dockerfile new file mode 100644 index 000000000..f88446e3f --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.9/ubi/Dockerfile @@ -0,0 +1,71 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.9" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.9.100.20210216T1317Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.9/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.9/ubuntu/Dockerfile new file mode 100644 index 000000000..935168a99 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/4.4.9/ubuntu/Dockerfile @@ -0,0 +1,79 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="4.4.9" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-4.4.9.100.20210216T1317Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.0/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.0/ubi/Dockerfile new file mode 100644 index 000000000..c2c257fbb --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.0/ubi/Dockerfile @@ -0,0 +1,71 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.0" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.0.100.20210710T1827Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.0/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.0/ubuntu/Dockerfile new file mode 100644 index 000000000..d1dc4b154 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.0/ubuntu/Dockerfile @@ -0,0 +1,79 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.0" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.0.100.20210710T1827Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.1/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.1/ubi/Dockerfile new file mode 100644 index 000000000..c159ce95f --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.1/ubi/Dockerfile @@ -0,0 +1,71 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.1" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.1.97.20210805T0614Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.1/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.1/ubuntu/Dockerfile new file mode 100644 index 000000000..b0811a91f --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.1/ubuntu/Dockerfile @@ -0,0 +1,79 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.1" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.1.97.20210805T0614Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.10/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.10/ubi/Dockerfile new file mode 100644 index 000000000..17f6d264b --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.10/ubi/Dockerfile @@ -0,0 +1,72 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.10" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.10.100.20220503T1652Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.10/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.10/ubuntu/Dockerfile new file mode 100644 index 000000000..a604f7c1d --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.10/ubuntu/Dockerfile @@ -0,0 +1,80 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.10" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.10.100.20220503T1652Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.11/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.11/ubi/Dockerfile new file mode 100644 index 000000000..465d356fc --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.11/ubi/Dockerfile @@ -0,0 +1,75 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.11" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.11.100.20220602T1040Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.11/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.11/ubuntu/Dockerfile new file mode 100644 index 000000000..48ce7c62a --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.11/ubuntu/Dockerfile @@ -0,0 +1,83 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.11" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.11.100.20220602T1040Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.12/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.12/ubi/Dockerfile new file mode 100644 index 000000000..7253e022e --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.12/ubi/Dockerfile @@ -0,0 +1,75 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.12" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.12.100.20220628T1838Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.12/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.12/ubuntu/Dockerfile new file mode 100644 index 000000000..3fe57cd83 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.12/ubuntu/Dockerfile @@ -0,0 +1,83 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.12" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.12.100.20220628T1838Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.13/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.13/ubi/Dockerfile new file mode 100644 index 000000000..1ad7975f8 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.13/ubi/Dockerfile @@ -0,0 +1,75 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.13" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.13.100.20220711T1809Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.13/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.13/ubuntu/Dockerfile new file mode 100644 index 000000000..2585a491d --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.13/ubuntu/Dockerfile @@ -0,0 +1,83 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.13" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.13.100.20220711T1809Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.14/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.14/ubi/Dockerfile new file mode 100644 index 000000000..d4fbc5745 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.14/ubi/Dockerfile @@ -0,0 +1,75 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.14" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.14.100.20220802T1010Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.14/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.14/ubuntu/Dockerfile new file mode 100644 index 000000000..939cc580f --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.14/ubuntu/Dockerfile @@ -0,0 +1,83 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.14" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.14.100.20220802T1010Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.15/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.15/ubi/Dockerfile new file mode 100644 index 000000000..13f60da1a --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.15/ubi/Dockerfile @@ -0,0 +1,75 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.15" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.15.100.20220916T2105Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.15/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.15/ubuntu/Dockerfile new file mode 100644 index 000000000..c3aec1545 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.15/ubuntu/Dockerfile @@ -0,0 +1,83 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.15" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.15.100.20220916T2105Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.16/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.16/ubi/Dockerfile new file mode 100644 index 000000000..dc1335b32 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.16/ubi/Dockerfile @@ -0,0 +1,75 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.16" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.16.100.20221019T0009Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.16/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.16/ubuntu/Dockerfile new file mode 100644 index 000000000..0c15dfee1 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.16/ubuntu/Dockerfile @@ -0,0 +1,83 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.16" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.16.100.20221019T0009Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.17/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.17/ubi/Dockerfile new file mode 100644 index 000000000..fcd543f6c --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.17/ubi/Dockerfile @@ -0,0 +1,75 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.17" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.17.100.20221115T1043Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.17/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.17/ubuntu/Dockerfile new file mode 100644 index 000000000..af5c7404f --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.17/ubuntu/Dockerfile @@ -0,0 +1,83 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.17" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.17.100.20221115T1043Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.2/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.2/ubi/Dockerfile new file mode 100644 index 000000000..45abbd22e --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.2/ubi/Dockerfile @@ -0,0 +1,75 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.2" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.2.100.20210901T1556Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.2/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.2/ubuntu/Dockerfile new file mode 100644 index 000000000..02b404aab --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.2/ubuntu/Dockerfile @@ -0,0 +1,83 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.2" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.2.100.20210901T1556Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.3/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.3/ubi/Dockerfile new file mode 100644 index 000000000..e6f15635e --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.3/ubi/Dockerfile @@ -0,0 +1,71 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.3" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.3.100.20211005T2044Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.3/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.3/ubuntu/Dockerfile new file mode 100644 index 000000000..ded465a4e --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.3/ubuntu/Dockerfile @@ -0,0 +1,79 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.3" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.3.100.20211005T2044Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.4/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.4/ubi/Dockerfile new file mode 100644 index 000000000..afafca490 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.4/ubi/Dockerfile @@ -0,0 +1,71 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.4" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.4.100.20211103T1316Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.4/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.4/ubuntu/Dockerfile new file mode 100644 index 000000000..64d76cb4d --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.4/ubuntu/Dockerfile @@ -0,0 +1,79 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.4" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.4.100.20211103T1316Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.5/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.5/ubi/Dockerfile new file mode 100644 index 000000000..9299b72a0 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.5/ubi/Dockerfile @@ -0,0 +1,72 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.5" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.5.100.20211201T1756Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.5/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.5/ubuntu/Dockerfile new file mode 100644 index 000000000..c37ae81f7 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.5/ubuntu/Dockerfile @@ -0,0 +1,80 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.5" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.5.100.20211201T1756Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.6/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.6/ubi/Dockerfile new file mode 100644 index 000000000..5b8821574 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.6/ubi/Dockerfile @@ -0,0 +1,72 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.6" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.6.100.20220114T1308Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.6/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.6/ubuntu/Dockerfile new file mode 100644 index 000000000..29617c3f6 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.6/ubuntu/Dockerfile @@ -0,0 +1,80 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.6" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.6.100.20220114T1308Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.7/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.7/ubi/Dockerfile new file mode 100644 index 000000000..1015aa29e --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.7/ubi/Dockerfile @@ -0,0 +1,72 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.7" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.7.100.20220217T0528Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.7/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.7/ubuntu/Dockerfile new file mode 100644 index 000000000..1ad9c93ad --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.7/ubuntu/Dockerfile @@ -0,0 +1,80 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.7" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.7.100.20220217T0528Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.8/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.8/ubi/Dockerfile new file mode 100644 index 000000000..68e62609a --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.8/ubi/Dockerfile @@ -0,0 +1,72 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.8" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.8.100.20220302T0204Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.8/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.8/ubuntu/Dockerfile new file mode 100644 index 000000000..9a509e28a --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.8/ubuntu/Dockerfile @@ -0,0 +1,80 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.8" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.8.100.20220302T0204Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.9/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.9/ubi/Dockerfile new file mode 100644 index 000000000..b573f2e2f --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.9/ubi/Dockerfile @@ -0,0 +1,72 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.9" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.9.100.20220407T0303Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.9/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.9/ubuntu/Dockerfile new file mode 100644 index 000000000..f897d7347 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/5.0.9/ubuntu/Dockerfile @@ -0,0 +1,80 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="5.0.9" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-5.0.9.100.20220407T0303Z-1.x86_64.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms-* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0775 "${MMS_LOG_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && chmod -R 0775 "${MMS_HOME}/tmp" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.0/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.0/ubi/Dockerfile new file mode 100644 index 000000000..caa53d62d --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.0/ubi/Dockerfile @@ -0,0 +1,75 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="6.0.0" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-6.0.0.100.20220711T2118Z.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.0/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.0/ubuntu/Dockerfile new file mode 100644 index 000000000..9f09c9b2b --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.0/ubuntu/Dockerfile @@ -0,0 +1,83 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="6.0.0" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-6.0.0.100.20220711T2118Z.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.1/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.1/ubi/Dockerfile new file mode 100644 index 000000000..444301457 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.1/ubi/Dockerfile @@ -0,0 +1,75 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="6.0.1" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-6.0.1.100.20220720T1206Z.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.1/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.1/ubuntu/Dockerfile new file mode 100644 index 000000000..41ef1de87 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.1/ubuntu/Dockerfile @@ -0,0 +1,83 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="6.0.1" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-6.0.1.100.20220720T1206Z.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.2/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.2/ubi/Dockerfile new file mode 100644 index 000000000..e383b80d2 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.2/ubi/Dockerfile @@ -0,0 +1,75 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="6.0.2" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-6.0.2.100.20220802T0955Z.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.2/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.2/ubuntu/Dockerfile new file mode 100644 index 000000000..d9c43a0f3 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.2/ubuntu/Dockerfile @@ -0,0 +1,83 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="6.0.2" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-6.0.2.100.20220802T0955Z.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.3/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.3/ubi/Dockerfile new file mode 100644 index 000000000..5179bc7d8 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.3/ubi/Dockerfile @@ -0,0 +1,75 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="6.0.3" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-6.0.3.100.20220830T1645Z.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.3/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.3/ubuntu/Dockerfile new file mode 100644 index 000000000..635eeb1c5 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.3/ubuntu/Dockerfile @@ -0,0 +1,83 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="6.0.3" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-6.0.3.100.20220830T1645Z.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.4/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.4/ubi/Dockerfile new file mode 100644 index 000000000..82fbf4d7d --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.4/ubi/Dockerfile @@ -0,0 +1,75 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="6.0.4" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-6.0.4.100.20221007T1747Z.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.4/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.4/ubuntu/Dockerfile new file mode 100644 index 000000000..25be66dca --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.4/ubuntu/Dockerfile @@ -0,0 +1,83 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="6.0.4" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-6.0.4.100.20221007T1747Z.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.5/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.5/ubi/Dockerfile new file mode 100644 index 000000000..d609b2865 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.5/ubi/Dockerfile @@ -0,0 +1,75 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="6.0.5" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-6.0.5.100.20221019T1059Z.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.5/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.5/ubuntu/Dockerfile new file mode 100644 index 000000000..5a3bc6353 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.5/ubuntu/Dockerfile @@ -0,0 +1,83 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="6.0.5" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-6.0.5.100.20221019T1059Z.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.6/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.6/ubi/Dockerfile new file mode 100644 index 000000000..a1266f277 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.6/ubi/Dockerfile @@ -0,0 +1,75 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="6.0.6" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-6.0.6.100.20221102T1837Z.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.6/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.6/ubuntu/Dockerfile new file mode 100644 index 000000000..ce25f7dc0 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.6/ubuntu/Dockerfile @@ -0,0 +1,83 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="6.0.6" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-6.0.6.100.20221102T1837Z.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.7/ubi/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.7/ubi/Dockerfile new file mode 100644 index 000000000..677f68c04 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.7/ubi/Dockerfile @@ -0,0 +1,75 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM registry.access.redhat.com/ubi8/ubi-minimal + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="6.0.7" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN microdnf install --disableplugin=subscription-manager -y \ + cyrus-sasl \ + cyrus-sasl-gssapi \ + cyrus-sasl-plain \ + krb5-libs \ + libcurl \ + libpcap \ + lm_sensors-libs \ + net-snmp \ + net-snmp-agent-libs \ + openldap \ + openssl \ + tar \ + rpm-libs \ + net-tools \ + procps-ng \ + ncurses + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-6.0.7.100.20221129T1435Z.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.7/ubuntu/Dockerfile b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.7/ubuntu/Dockerfile new file mode 100644 index 000000000..a6bdd4dc3 --- /dev/null +++ b/public/dockerfiles/mongodb-enterprise-ops-manager/6.0.7/ubuntu/Dockerfile @@ -0,0 +1,83 @@ +ARG imagebase +FROM ${imagebase} as base + +FROM ubuntu:20.04 + + +LABEL name="MongoDB Enterprise Ops Manager" \ + maintainer="support@mongodb.com" \ + vendor="MongoDB" \ + version="6.0.7" \ + release="1" \ + summary="MongoDB Enterprise Ops Manager Image" \ + description="MongoDB Enterprise Ops Manager" + + +ENV MMS_HOME /mongodb-ops-manager +ENV MMS_PROP_FILE ${MMS_HOME}/conf/conf-mms.properties +ENV MMS_CONF_FILE ${MMS_HOME}/conf/mms.conf +ENV MMS_LOG_DIR ${MMS_HOME}/logs +ENV MMS_TMP_DIR ${MMS_HOME}/tmp + +EXPOSE 8080 + +# OpsManager docker image needs to have the MongoDB dependencies because the +# backup daemon is running its database locally + +RUN apt-get -qq update \ + && apt-get -y -qq install \ + apt-utils \ + ca-certificates \ + curl \ + libsasl2-2 \ + net-tools \ + netcat \ + procps \ + libgssapi-krb5-2 \ + libkrb5-dbg \ + libldap-2.4-2 \ + libpcap0.8 \ + libpci3 \ + libwrap0 \ + libcurl4 \ + liblzma5 \ + libsasl2-modules \ + libsasl2-modules-gssapi-mit\ + openssl \ + snmp \ + && apt-get upgrade -y -qq \ + && apt-get dist-upgrade -y -qq \ + && rm -rf /var/lib/apt/lists/* + + + +COPY --from=base /data/licenses /licenses/ + + + +RUN curl --fail -L -o ops_manager.tar.gz https://downloads.mongodb.com/on-prem-mms/tar/mongodb-mms-6.0.7.100.20221129T1435Z.tar.gz \ + && tar -xzf ops_manager.tar.gz \ + && rm ops_manager.tar.gz \ + && mv mongodb-mms* "${MMS_HOME}" + + +# permissions +RUN chmod -R 0777 "${MMS_LOG_DIR}" \ + && chmod -R 0777 "${MMS_TMP_DIR}" \ + && chmod -R 0775 "${MMS_HOME}/conf" \ + && chmod -R 0775 "${MMS_HOME}/jdk" \ + && mkdir "${MMS_HOME}/mongodb-releases/" \ + && chmod -R 0775 "${MMS_HOME}/mongodb-releases" \ + && chmod -R 0777 "${MMS_CONF_FILE}" \ + && chmod -R 0777 "${MMS_PROP_FILE}" + +# The "${MMS_HOME}/conf" will be populated by the docker-entry-point.sh. +# For now we need to move into the templates directory. +RUN cp -r "${MMS_HOME}/conf" "${MMS_HOME}/conf-template" + +USER 2000 + +# operator to change the entrypoint to: /mongodb-ops-manager/bin/mongodb-mms start_mms (or a wrapper around this) +ENTRYPOINT [ "sleep infinity" ] + + diff --git a/public/docs/assets/image--000.png b/public/docs/assets/image--000.png new file mode 100644 index 0000000000000000000000000000000000000000..7c2ee0d6e242874572062d0f1bab3320ebd3155a GIT binary patch literal 564411 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

bTcZOq&WkuS@U<1e6yJ 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�MM3 z$uUIYQyhs$rDLZ2*YBCYW(GU280n4cHCJQmZ$6oO(S_Bf%Ra9Zq?kOsVz8CxCGU(3 z`DKsYI|rkmLf^6`2_ktA_*S94e;8mH#%@q~;NFFQI8aKf0vii9YGdjE}B_!y!$E&otppE+ci$HEAu#dKJsuswzbt5McZyUS`E8`Xj1cHBIbB?1emI zm10e}8s_F6$w22@#L`HRfJ`({@(m&s?%EufdqC+V7mEX%Y(zV>CC?`*A$w}&;VW$e zy{wauZ7i9mDMLd<9V{&HO^618{Ff$2ySzA>l)M-YJX8vBYFb_kU?=242Frus-N;z$ z5{1NpEec)>X9OjOFHh-q&-g4|1aJtS_ONIi}#MarL0dK(NTF=+k>cJ}IS!&XVz2!#y7Vzuo1sk%! z!a}RU?(XjMkYAw4!BI#eP5OxP!O)IKZY+6-qCOE)wXQ5DCrDD1?&A-~S(?U1HF_LG0}dv~Tdph&c>Fl-Ok})g&}DfW%`& z*AdVbc^pMgof-RVGuGfaK3Q|N8Q4$O;P~ z@TYm&YY!g80PC~!1NPrO>wox9qbMd~_YQIz;5d2a*BiLK(}J*Zc(O~7z%BrRC?2=q zL-c0U8FvOZsD`U;_^BG@@H*0F;={4N#!je<#TpmHp|%cW-`EzrE-vzk~aY z$IC?1FNmQB0Or2;;ffJhMS-MPyDHF_I{v9S+feq<(yWJAp!b_Sv|o~LaeMbRw<8f)x!bHr?V{g|U^aPP}:$ndMta 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 iPD>zQg?PZ9b=)7=wtM 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 Y4gvq?56~nV{#-vzHQh_-3)dq47yEi>x&QzG literal 0 HcmV?d00001 diff --git a/public/docs/assets/image--002.png b/public/docs/assets/image--002.png new file mode 100644 index 0000000000000000000000000000000000000000..cbebc06e79040cfc735816f8ae049a206331fdad GIT binary patch literal 267945 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 z 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%0DkY8_U=NS9IqTk2euj#&> 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

    m_vf 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

    <~P?y?rXEGG9U=QH-C|8_9ZCk?Qu+@1@A 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 z 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


    6d@wUiWKeK?R&di2^0IDDWyG2 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�<0K7(d_v-YH$ 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
    (5W3unuIm_ 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 z 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�p1ez*?B!VsPWjCl!sR*Cw2maj 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 literal 0 HcmV?d00001 diff --git a/public/docs/assets/image--008.png b/public/docs/assets/image--008.png new file mode 100644 index 0000000000000000000000000000000000000000..ef776fd77a1a69803c565190e0da3018a5c3eac6 GIT binary patch literal 429946 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 @z!oo--W(KNSSswE8R4|a` zM#9+phOA6ftKB}soDk`UCSLjI+6Wi?&(xH(<-nzoPEm?eN}kyuP8~oZ zENpm5`oF)166ab@&AR(k91D)U5VVnc)Nov|`nsP+e)XbmaC{U)U^~Ybb0z0IEjHRS z;gf+u8`r0MtekI-yuMs-upZMG?9X9J+;{o@!=iKH{okaYJ&@VmZvU4e;R8$SId*}h zR&!*RE1|V|R;^|%lrN>X(TYcx>xg`+ej==292-XVd0%B2-@dhyxTTS&=6u!??i7gM z>r#=I>&vY*c@%pGDq7s!7aX)I``=yj5UBVj=k0G6ELwkwjaYdij`sXD2_NGkUZsgV zh17Fqi~@Pt3VNdLM`;(&G_xcKjh*V}FY@#-3}o~TBU>8h3hSw4t2Q}O>#g$X$|p;j z*(1^y*>p=U3G2Lgwkj;dJUDdW(badh8=IN3+r1JuJz9TEN$l+}{rv9k4RhMu6XIfg`&l)j~>oWr|?qi zIi_yW66Ro)5#XQgcO$8HB)$8MhVAa1G8M8&=~Gvii0Swdq~Cv%_lj6* zBuS_}`1r)6*$=KZdu5Q)8F9A=c*q4e+#^L+c6KazSIuC@Vy&f$N*`TpqCF^pq>iyZ-<_fI%t+w6qoWYo z^?I1aqY|{5Oi_uEBc)anT5k`SL`OyrqGv-vNofO=rx!z*u$yoY2r%r!F2aNtMrLMs ze3Zf>+wj0OpjR#MmFutWU9p=LpWi9RIhb>BXCCAGmJ?-F?Jn2weAwP zNQfOs(;z@-D2mBPP4xUKiaR7d&jJJIz;O81*B59UQJd3#F2In`$yM(%xvV8<$>2|h zGlg_i{GOcT6BGMF8Xm!W?%Xxp#Dfc-uHsGT^uenY7v?gsWZ43zst7hrH3ftYi2U6B zMNVH|Up!|@O3Ihf(WAIIjZ*h01eq6gcZEGR#DL(JJN`4}iaxxvPrJ;G;qfAo;?_-; zy2sTDj*qia>&u<|u2kOH@L;jb3bxG5X^;7CxB0@+oh@=SPptiQkJ&l1;a@|^T&!FD z$PZYjM#=E<(^Lxt_V$HQ@}8x>=MeWng}$rbm+0*}j5}Y&9rIHZ*m5DKgH_$c zT9;LhU8m^nTY^&p6O-Pt;EdPQ?xp3D8PydIA%4MUZ~|=G7(9|5701%<)BdR>IAFzz zc=yFh3J0-TkV@$@MZ(2L>ugCXG!kO%A0`W~a19;VWlzfYR)bmF5*k_+9+3~8SuK2R zsp_a{gsN(Sj^`Bn9d$7ko-A9ZgBBAvcJA59xy?P;=+hOEky=BB-*gcNIm{_4V?@V1 z%F7v=)+5IY`h?k1@3$_$=E^b7W|E_ncCuuvYYRV^U~MO^LFe+KJnr7|!fboalqh3^ zF~Pbhf8P~y);Gt3OYEpi4T4Ueb^fTO`|k8rHtVA5PJ3ORJ{|MY8jF&0EBPS1FTMuh zX(6tTn(0h*^#YH5-Kr{R*OQJmTJ5OyKH_$)!f}>!La~$QqcD4RaXU1TFp2#u^g1Ue zcSdHxb#afM-sXX7nMC2U=Vd)Scraq`)E<6EM@L&sHi55UtzooV4ft#;7-59ve1c{V z3~0zNg65ZnDg%Ag74TlJpx#FWE}Cl;onKm#Xum#LklJps!HR+&ww35|WN`X{K!RzDVYX1l@N zyN_U?fuv1URn>Xy-1v@UbAmW+`lLYn(!zqSYAD^`;#je;(&@;{R^wV+T2j^2G_bS` zL|2^W%lhpvA#$fTNVkFc9G~Dxm(J;jVF;vqO!JnYr}zexKqTs(qhlDZ7Yd0Hu6xS` za|^#EqW2{hrV=aWFj29n%$buGBiMTTN$95)=iOdJl<@)*7Lt)M6ID4H+c;lPs} zGfuD2uhnQD#y~thR4=!Zqy-}l@ZM6f)Bd&ph7pvMj`bJLM4TNnuZ_QDY3mU$lhew2 zc;-y+kp$6!+A01so=SvRMe}AO=Ce^(yx6{LU$@TSJkTOwBszJstmxaDZ|O7YH7z-o zVOC!&3=a#K`&QaS+Vmuti!Sdw(C%&1aPL%~j!@rQmE0NCe&)13ck}I^9PUp6f({|rfgwjnpD+&+1q9A-TsX`D{gE(dnCf-POvMPyKf$CS`XoC z2(6S%s++&?sKhSFsj7lErq}9INEhc+TVA2!k42m3w^ACOJm8&rCBxTygl)e#bA+8{ zx_MZ26hk}Rb?JxVtKYW1Id(v!!=+1Lvw2-CU(avg^-<-718?=jWh#%-^Wq3IDE&G# zY4b#$g@w7{?Dm2KTXC=+-9P|v$dQa6<85YnBRX)3c6NrNQkP!L{H0L2=dFeWm2BgW zW)Dl^^eqY809_*U$xwk^EpsjR3CJ=Ef_f+3d@RgmDX(51K_%EJeyI;ASGWcLVO+Du3yPVEO z!SJ3BK>@3Wf9~b|##Ck)o+qDHYEl{dJ^c*DZZcV^2XW&*EJ-uPJrbVk+bgt zU-(@kqoBGvYZn(j9*VF{6;$A;%9ncY2Ldkx`LqHHxyWG-M6j;}!VOMZ(Siiiv#7K| z>Fv(5_JV`~{Hs#{tdBY+`hiG7>B`2__pBc(&s z2&qAtkILo2g)&n;xZ(qZ!`~8&ap)CI&u?sqPdE_0{U^nvsp>P|wV_37#>>x-O}Dtg zN$Yn;T)YueoK)1*ezeF-#3Nq>K?U#aW1y;-da|;5aGE;D(+y8cg9C|8b_oHvB%gp@Nyh71#4uwDAq{X zvy;63+YT>DoClp|kci-i zYDe1S?fw1!S)$di-y!Q`3drnBC4Mwm3_r1~?cOBh^GuV@Y~$N4Q_(Bsix7!1TmFq4 z?4HkY3WJAL55*(Q!zMJmc8wq6u#Zg53!Ew89q@EM$SZjCVEXQzffaS8oeWE%H|V){ ztdjv$4C<2+(0KzRlP%YzB^6^%?T-;<)-jlRCrO;N2@jp1rXphNppwyGOpHDjy!(XQRtnqr>)i&m%9g?crMtFY6B_n~w;+*NEm(NX_l!VajgL?V`M2 z&E^<(opYkqC?`sJuZyEff~2sMz(|NsgJe>*Qfi%7_TJ9x!3*nT$9p7i&1?zXI;%&xgeu+YVOvk+sqF;{0-Mm~7m^-#P>>^ge!W0tG@Y#LB zsQJ=Yy?3IY=5>bH`>9$PD3_G$8UK!WtM9)0P4ZjD7KvMvOqIOT#I>q7XWl&D4e+l% zlb(T5$IR~0K`-Vmvqrk3*v%QLj#t%b3KXQS1_e&YIukG(fV|fT{w4LXE0tZy$oFfjCFHL8vQ_ zp^lABPOc{;0+q3}PJZN=wDPWf9KwQcV*+U5SP4^j z_D!9lc|sjniy{Dxjv4qChNJ98phiW5E(3lL^k~G*bR=FF`EdmW1_BGck0f{4AU?G( zO-&fCTfCE<=ZG&|LyuwPX#FrBc)?#98i3P^ii%>cwDX)Z@HB`zap$;EJsllSC$Ww0 z+tmME8%s+MB`zsP{)SWt*MZ$vCn%a92vjLLcX%)=d`HsHX5I)NNmjk)LreQ9)V`Rv zs`QvnM-o)u9P>*)66re{b)rHuw%Bdo=YH zXQ)fZnFgDi>fJZZ*m;?AUjC$-3#8-EON9l;rHzHftt=8Bckh&C6%W2%neffdC|jd3 z^!VF1?y0ZsX4Lz0WnS#}GEIE(v5TAN&6aiK%GT9Q2Y$akOP##0nStVS>EA@Uc8xAv ze0{M;_u*ESaHpWY-a4E6`Be=oG)rNpm)F_0M(@e{XcneaA3K~^b&)o1{n*CXEg?%e zHu~}Nd*@1J@_R{@N`=y)dI4V9?d{$sl?fAhJ6y@a*anLnMXHS)Kao4BCzMNjJWgKN zffy#wOt(B2ybWmEi3)Mf@pluY8m3;`-IBJrjcutMqu#FRX>IqKvAsH{C$5^DhyL~C z+vvInyor}tIW5~%x>9BmcR7<4JXE1oWVkYSLfzZ4pk-+$XwB%0NP^5@d1-T_&vxwFNs!M=1*^0< zLP+5N?&n&;xr4$82WAAeaG6;Ujb?D#^9c%$WV<+S8^p*;W@IaP?WQQy0%tHeaL zLw%-Ewi@8!EvTrkCbnpei}s(b$|X$bOO6=U#vuEE5ujYOcmsx~)Jbg4NzB0z;K7q8 zPxMm;F_i+pjbsf-z;(_qE~5Bu`uurs>E`;B^K}2#y1OrM#U88jn@@84+#|7pN#rLa zCP0;4H&#YTHMEnM9Q(mHYALX%HO?ixlJIykq8*wr5@3+sOJ+Gms3`zLpavpu0?K#R#UIWeTO2A_67H) zw(0w$O(Ocgg#(1zL13#wqUf<)$x_shw(!0C_X(yM(XU^Rz*rg4FKxf}KSPhZ_y&j9 z)PQH*1!Yh2181bR`jy*x#`IP3Qfvz?zv}o{sx9Gr`k36#-MdO?CItedQjdopwG61; zy)0XxxAozJIEUXJ!eI$QhlXZcy!Em##ZC07Jx9yH}LYd^{$aP6{NRi!v!gwwb8tceHTzj4wggw32#?V!oJm1X@J zi&yy3!WFjR0@*UduZQLavH~K~^mYB3H+WH#JX`R-_Q;K%YzLBPX0dw zRP7b>6YAM=;1&4`dwoM_hZAJ8hQ-~YB-wR>o-Q9A-)C*Z{<*H4xA;ka%euQe!Dp*@ zbX|T1d+_>x6WK_qc-d^{gHBYd59h14%TPYb=iP7khUb~_hJm`z)~(TX(jG584DDOO z_GYGQ8W7X158mCyx!pB<%X$*^?d|)yWoyRdcfFQ7@1@wsLSd8Zbxo4NOu`MaiL!>y)QtAletxoSC7yy~S?N3TI70 z`B@_i`x9~Qaum{5j4yvv1wUJ!39k)II(4{z_Q?*ctKTl(4Q5`eEs5KN zx#ttMov6^geE*`x%V+%j2icf6tSb{Acy7L{UK_f`(7={pOo9Qur6l~m-uKm|*V+PR zm<>ciNLkSE#i*E@$!$gbwgL>Kss(l)ATW-dbuazU+{H_Qqy!VwfnDtIM3VD)7zNXT zz1~2?4P9I!Fb~;slqARj;=K;DH;f|0<}zNy;Lxid(7NTHZ5cmN704=s=fJ>#K5^##C~R$;355D^enX@(=v#5tuhyUM5-8r{!V&TYPXj0j z2pZ)WXJQ+>WSAROch?E*Pjt^nm?k`pW>>^~bo&7n&AMYa1gtprAsz>uohQ1A1A$yg z^jJNG)KXPV?TrREd~)$Zz)vWGaqSo*0-jf%$EG+m4rtpk)|T^9;L+Eu%#EVu0Y)3N ztk)7-di4U(Bmkx4i7x%KfB>4^I|53G(cF5%j+2ZKBp?DIdBgjW7`*rkiZ;+jYI4k{ z=0wuYJI|iL{Bd0qlf5kh#b*xv3)UG}`nmtUz>xkMAsM2-z}9-552sKG=+gHuF?qYi z>G~-V3Jh|uj>FjrB8O_>H4hdbo>^dL6 z4HT8JYpcZxw!)w9)mG9T*naHW8J6vuTOFv>556~Nq?3~LOMMwQ^O_oQhv-fFe0aj%b8F&k~p&!@!0lY7s!GM!+~N&gb& z5%A!FafKDzmyjgk1Oo;3T=q65HOeX}+VyJ^gKo!MZcFOvj^`H(%6g^dMXPkMGM_m- zMV|58+-cBAmF&4^xs~$cfl1zr;aj$DYH#Zfe-tfLqxa|`TjH*`s)~|P@yaRwu^T(l zg?ORs*P!8LmhV3-vF3 z`mgU@!l^en@1A+F2MrXR3w*zh{}j4f>o%Ww(QN3~!DPXl!5>S9k;btVTtwi*$EWkM zCc|w~ZaJTqR-c{e?WwU-!P63s%;=eC^in6i3oKU*!ZqkMB=mmF&`jqH?tK&(EAtqE zawh0W-_jsipKQyCgL`Qx7M;w$Nwia&@zRr;6i#B3JDVNRm>Zo#ml|mgBW3XQc#K@q z1w;zLA-so$m;((|Wn^TWB+(i(w~PcJ2^bWLzr*zO=vYYPp0DKn`Rf-3&VFrctFEfr z0csV-%%hPxPHID*}CO_-5%V83k{qnp$pog17H#!G;FIt{GJ z`4yQHkP;!fA&gyVSeIWCqTU@;cQ+3;7vL&TIGuC= z6h-BP1A$nCS`eXTY+~X(?N#5nbRxmBv|)K!804+m#lbsJ{osF{F-ETF(w-*4*Xp6e zJNsJ31W*^v&(Bl)RfDFcT{vMS{N>Z9-T0L-5kffZ?CVQxHqxOd8?ZvP$2|24`U3sY zFPWUTk`7{9$09%>2BEuAq_E!6L5~K{)e{x4Y06#JH87}Fr`}KLixP=7T;|8z3dX@K zjyRs`2jp8xnx{cgsbgjJRs90>?j7_9(MXh_6LON=#3i8YMU=)t0G%kDKG6S}Vi=p6 z8pWs#ihcX`2iAIowio^b_<8I(ARZYJOlLyiUI_I!pkL-ZP&(Ci(Hd;0tbOkFWmEiG z^q(yV+~!2~yGN$ORlWz(6=X5d5y)!owF^A7N8hCos;vVesr`oYpVZwyYGjA092G z4H*1tlFchSmtEUp&B*iBLiKw_;x@5*d(DUjHcvW>%~;_7W(34=n4T|*)BQ`|*EjZK z`{{HUub|C$H#h7rI=k0b@db8$YX~c9=L@VT;jX!%J$LcUzU`Hc2QO?0Mw`>?=;>_m z=Kmb6{mi#pyr*gE^igyAD_qkxErTl#gnr}X^E$4?iK1u5^vfx6=Vmn*&-~=K)+`a6 zpLBao74Z7xbmbX}X`-yvIg{tgKi5UGILUFIC*R!%v07@PD)7Ub@Eb7b(GdG zif&!B?2Qmc^cBycCw4`zoEr=+6@mOMDkzbrrW1SNk3~KYa2Zd_&g0 z@P|`r{VMNPFu)(3=JR`3O6?d+w$JVQz_BSl z^0+)%X!Z!1io~}k>#@-^)0Pd6dkQg(^7p7}U+wu{M2a6CZSCI4+O^4Kv88EE#B)UM z+78vn(;V)foM$wbWdALPcklif{z?ARS&DAM!HLP-zR*v7eC=)3$NqWAtP(tXOX^Dx z&%}Th^6AS}vbz`AAG{TCDUN(K(a zUV49;y)R!e1mOHUjXA1MauAD%~_olET9`mg`D|9M(m|G$J2 zQh-_e@Ygc;zg#2!Ny2n*$v@F&(u+PdMLeAP_ZQ7(CH()t>i>N-|NjI0pEdITxhz(& zE0@(r3b<3p`}4Scw)*JmF{qgy{ht^A?{8*B4!Uk`&ChG?vX7V$2^~8=M1Ej;r~9uS zntxsTE6H#AiBwh9)z)U$8(6)&j!A02OyynXlJL=8Yb`wWzu)E}1m zp{QoyaD!mRY(X<~$-1Cs_lneVif6_DW$C9~Enk{aJvFaodZ#mOG9#oaMpIa{SM)`QP8nDof(E;e#TrgtM~?Vy5|rl*mux*Z%0DUa0-~WRaS@ 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�fS*qGQ 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 literal 0 HcmV?d00001 diff --git a/public/docs/assets/image--014.png b/public/docs/assets/image--014.png new file mode 100644 index 0000000000000000000000000000000000000000..8d0c75e7a564d80291bef5aaec249fc2bf9d3ec3 GIT binary patch literal 199903 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�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 U*f;8kCvfTM8t%;8VHxnh0MMj=5dZ)H literal 0 HcmV?d00001 diff --git a/public/docs/assets/image--030.png b/public/docs/assets/image--030.png new file mode 100644 index 0000000000000000000000000000000000000000..3321b971e5f73c2c5415a6e9ce2897fd4186d647 GIT binary patch literal 329398 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%0AOPRG5gu&wRo`+4L8C%mt6 z%S8#Dt)9NyD(c11;B&PxcDY;PKQ1d*4OaG?Y$DrHsxC7$)AMGwtR8fz!StKXUJ06; zCQ~+JT9Ir`-ya``T!MkUYu8fOS#5DSt(Bi$Q>{|Wj8o*Y*QV=yy(R2HdBGq=2x$Tv zgOwW}rKRoP<`b8j-nwg4@X((Mhp$R&yq%su)_aD^n#(FSmP^A=1=eKGJH9D3mU&^Dj4pT*7?TfTRn;ipRDNbih2hM}3MuWV^;FEAcCIM{spq zNXui1A1@{rZXNh8rfHc-e8xL2S--JQY9q;RD*2hFj?S-Jd%RMVd)%vkJ*4`^yY2lA zNBtcuXEF|!0-2~7**XC1$F0`P%|k%rgL4zPo9e8P zU1fDr;YDrI%P(M8=b;2Cv{floeSWS?O6gXA*zJ!0NVDMnSPpA5QY#fb;Sa3-iVOA> z-7jiuDS9;Qg4vvoXX01jT%r0_y(?;!W8BO^ zkQV^kwpQcmAA5U5mqS3HCIl|l%Cj6M3gG7Kwg%l;{7z|IrM5Mt28;}QRu{0kAC-ny z;{5!Po;AW(TNo_~4)^KPciifGw|!VL$H2`iJs%GafwHzWhqR3))f2gX$=967GPz9- z1x<~&T+Duzlz&UOviZ7XYQKp}t1p|6F0pE>wW`8mRp2cGv;~tmP!|1TZqW{oNFfJMLKKDeY?(o*uNmWlAEh;wmJJft{*W8%2y4aB9Or5&p;jLfO zbUOxYKU`TdxpiLk%*nbG67YZwLn!~Kf9P$Gjv75Tpl18A1RBW@oW%$ZVZk*@4nGdRwI%d+2qbrqBF&mT zJ9XmP;->e9wT??C_bR$vrEHppHXEFZror|0HN6+$Rfp=kl~T~ebh}a5OG-#y20!ir zgOemA=x^8#Z`atW)uRJ}81|nS!9{1Ehe0AkJB45g%(r3eESUk^13_P`YU?DG z-Qen(-7Lx!&lID6nMR_oQM5Hw@*TV;UI5(;$ZBv^_zkTQh+HEeV1+I3PUI)mQ?Pr2%rHrbgUd|FlbpSHx9%wrB_9D|R4vhrlle zR)LTTb}|({z$U=o)S_ch%GBve#Oib780a=_{5Kq`;(diIsu+|o% zA;Hl>JjV8GkY)lBLL=+^o9;U&2}}C2ExL?T2OmMZQ7 z8xL+CY1+e8vx9ep?l{EDh0zf9Qg}KU%naS(wYnep)3b3sIZXL7y6A#8Jg8Sjd0}!H z{UF+S@I2`?0Aa0`B$lpa5k4;86@Svx3zkEVAW%$W5yIL(|rTZb1>+?fq|*{&NTG*SoU zzkTqlTt|+-bv$Zk0d}J3TD!;Xuv-n3OlRkByDOzzI`wg1!D0fA#%qauMB;peI^rG3 zSjc-L=d|20i8O)n^GM;_fxC)s{|s+D#9ksBBtwO{%sTCCB<(T}5R__*C`H%=6XhGo z8Zvx9Ihl4y>VBL22 z#yzV6_cRw4jO6lI)*LH)W3c^TV=T95JlW&kzSaF~zy0&X)ggVdHJ%dSS}Cu_^*prt z&Tk(Ph>=+hC+qTbokNYF3K1HJ3#5N4slV5+%K|ZL(&}qV)PDtrl~@O1jLzC4eK>n1 zOCgFGC1^4E?RK!S`}^O$n+s}^0Si?d4<(w9z8XRQsecc>pMTZRO-MASOJ^Ec>$H2p zQWLTEL}q&+`Iw7PA@c82f)>eklJhBb$~oqih3gFKf}Z2cFQ@8$6<6{jh%zxV_4TtA zqg{0EiYFPIPZ6kocyS`eylbV4dLIc@BeH63vdSOX?~u(`%MVQ_IyPBU%`|Gfpc}F_ z>}59-6DT01&f@g={y=Gx+j3b0_lO8K3%jqnp~LSjn><9fIc%>8gFg?bx@NAue>&A% zDg1zE%HHDH7D|o3mS1~axn05|Z(Hla<{ujBUanT}d>wWWH#H6wnYC7{c~>W=>`-yE zNZ&KT#KfvD`q`d+hL=?StkdIPYQ;DHfBcH+^CwjN4#fZYAIqVgtN%ok-`Dql{wH}x zkB_S2TmR2rw}#!*v~(Q!|MTw#i~s-r-O=N}4uk)Bu|Flp{Km8X^D=+`zt4TwDC+*7 zSNPw5em*jNy6gYEPVqOP^R#-DTuxZMx1fuj(bygx4z*Kz=;vmZ-Zu3A`T5XF?atMy z!weRG3{h=Y|6tzwWjGbUxFw$UJH!A?uZ3aVEpn~uE=d3PPyavu^nBtz_hyYbi)E36 zf^x89Hl5`Jsf)j{zF%innKO{r?AfCK9GB$doU+e8>B&RU?*F;usmj3W!u|i_wm;Xd zQf$jz*RM0sJkgBt%bJ=e?iPL@^0%2O+{e234kh308R|4>$88&NjDf&Y^Htb4?N-p@ zx{@JkqReMwqp@KJSuIy|fx*H&DPr=)=MoZBWGLgIN9GocE9(9yGWc`LFUuKkxFc|N zKTZQw$4o^c9as6wZ7_{TT{$0vV<9z`xwv1!OO0RgN5Yd5mo)vq&1Hl4upYLfJdASEEhPpO@rgQ^!bgqREuo0qUl00FQTCxv1gX2L^= zqkp0%X$BEg*1*?23E+$%gGIsA{yWJmkQ+gs=wYy!bG(n2K9?VS#*=!4Q-a1J(qLdc z4sAqld#WdWId^gM0C6d~xne5i*s+Xwbo(;h^`0ieeN0Bg@0eT&56>&?jgFq1t|;+a zhb<|=c_oZ{7|Tu-FOmX!Bk?j_Qx@=afvU;P7Mb(~`G&_OpJJISxTB&0USLh%O5kx7 z_PRCo+z{7;TunTBT75qEg+E^GkFC@G^I1Rd-A-A;a(}3rQTs{@>y91TIZU%&c1nQqZqx&(F9ZLDGU<58XA&TEUK` zL%ku+C2apOgXy(Ai62cxZA1PDokcqnWjYUkw6n?4l@JZvBd}vZ8ktDIBIihA z>ivg_sSQ2J9BA@esky&5c`>dh@}~MSx7LkxdT3Hw^~=;Sog_MK47lEpf*j_dd6Iok zvKyM%@~#Up%_Z5?X{}!i-#_h(U9qZeLpQ*h{}JVWcDUPhU%(m(&EV#2hw8F?v)Y!R zP_?kuHUsy@&OPqb*Vym2(5-#DF{SRY>rXe!HeE8j&p8;zk=Aj-k(_D7Un3!9$^UV+ z;6xRNs(Eynr4xc${*vrMR9UpT1h1YX3)ryS`tQm>x|-;Vg?BdGP`bDWqRUxs^gTLf z>N?l=K`x7jO-B6ITvqe}eWj|$5|}VSJ4V8YfviEoJFJjrqB_K(LL)z$?*>(c5!^lS zt(gIo8k38Trk|Zf+rrMKFDK5<-A%$kVncdE8(Zd3@a30ZgpDyS8NxzF=ya_Ux}!ov z+y)5;SqRK4;e~)I(=6KjE}VyxS(l=8tkh)LRTnnDEw}dGcw_NzdrVGbr|-^%<+Y5G z7In3tI^#J4(Sp?&5fOau8NrqWN~|7ng|yo_hDke1=c@l-Cr*)+{QaY;T{~C1$@J)8 zw9uozEM$atd#=gE|ICaJ*IfDdv;BDfrJ*coP(RX&pEEC)--?>+a^}vPjW7<$*+dxz zM^DV(woeL?{3w6-Jb|0Sl0;XuzL<;2KthwUu~hl^RH6MBHV0yT0k#G$gz9z=HDvB? zQZs^t5He6ZP&H6HusOktruvP`Qy5eLwKX|e3?{@gxew(LDN=YHF9vqYDky=hbm1T< zu+o>BM=BpzJ)fAZ*@JV7i?>TEnD@lw&}x*x5`qyZW=%Wm^u}|QF4?kCoq2nRD%f9? zSV${Hvzq)3;GFD>W1{2m1us*4HEtDMX?4-AOUgg`pJJ=sl%8=}9qGVDU-|#2dlP7`^Zx()D@BX;jn>JOjEp3eB-;1V zMw^I~7O9XZTS=QRWmMXf788{u8cRtsLNUfjk|ZR_mh9X8c=P-J@9UcX_5a`J{LX#u zbMEVOt}{!c@Avb0zhBGq^?W{G*Vf-wG-;P17ix0E|)ZELkmY~9Du8~IG8NuaCW+tRoX$Irk{4L#$91`-K9gf)6t#sK7acc%}Q z{w+~0MGlFXZBs4QJUU>!;Zjye>Vem~Cb#sjoGaYpXYl)#tSf^bw{6L(4v26#-O}aC z%Lx~5nQ}}0og05b6Xs55`wqg3z+xy+=(`5Y6)xhC5(L_YnCM#iHem^Ld0Fx82~|tK zR#c}pc*NXl+xPL^@d?h^5f9d?!chZy5~F-pX4BY29%|+;usae zKYrSv*u@3++a9M~v`C%yBxS_Vf8;!mMkQyrtv|Fqn$cN-lqc6~>)x`r2E=>&cWR%b zdd;11EvHOq!BZbMi7XZIX#dqxmwTUguUv9`&X2$EE$|Fcsop?AgVrY9Qgg4P_2HaH z75JhIhbl#TAr(84&d|Q}6{_)PG_#*erYlP{?;;90B-?vre)>m6$`h0Q6Gd9B;0_T2 z<&R2{0cpV2yeYAZCg4(?1O+|$)AK?*-1g1;)^?g{eioU8ztB*B0{4_jrb@|$TRb=n zL$0mAoBVXw#w?gHf@xS{OZ`IyKzyT%0Zd@jbtgGwyS`!N_Q@AD@`=nWxPwQAsrn!Ttxb?c5dSHTCRwUz$-E>YsI% zKb+5D95OrO;n*Y}r=}f~FPhZ9`x50vrSlZS5vB(#IpJVM}d^58)<_G z=Af#~X0Nk^h1V`zFgm=l;0EZ(O3eR^S!>YERjzgLu0M%1l*imUepx*lx5l9#6PRk% zlL5%Lg*{P*CViu@o1yqAt+}G@ydvC%m<8vo_H^|bjX5r4#s_T}O|nNu>^yz7)nSQZ z!wvlHxD~O%S)vD)NRGviYKnI#SeBv5`^eU#{;LShDH}Y(rOmnaz;ym28cAMI@a^Cr zmBh6!O&+b-V6>0V`X4H>FoB&yNn(DG&V8!arDeqv*F4plCd<=Xd-rwVC)d^|quyqi zYhX!$so?dD5;&QB6h6=!%Lp4ieaqR7SGFA++$~7fl~V#c5>|8%oRma<2HagG$b05} zL-R;pka8$XNnsLt>@9nms*n42;zgravfPlqJN1fQ`uLvln35h#Fq@h=wka_%!7Osm zJsyFE!&{AD{7+3y%#2R6n8ap*c7)=i}|N~&R2na~*YUk8~j8df&q-dceqN9V%2KR=_fEq+%{;ZftFTN41zF*MlVzDEP z2l-fu1rAo~q1l$=9X!fOfYn1Uf!htZp8lAXDBpyW9&rWamzn{O!`o%(Bt`iFn+*OJ z4Iobd_z{)^)Fq*u>D);pNtjAedun`st1icHh5;%qM1`QYLTKOaIl`3(%M^k_AZBW+ z7dK?$)oHG{MS@N>J=OY_N?3;!Kv~xWYp0-$!`)aK8NhmXdIa1WC8%0Hj!k0odps&MKXr33xY|epd`Q z=zh#XmDiM!c6c)Q&#v4Yh?x_tGy($I#-6c~FtM}OXhLiB%j|yvI16Dx!rxfAB z?Rxq0$q1P*5{av1;hdSPhernZb+|+1=U-dm`uo>mnwpcm6j2`U-}yTvCu7hur=)5%zhMEJHm1xE*;1 zcl``a{uLT<-jPKA%oQG}cxikgdMNOez>J@Yu{7!CpEkB>JxU%fKeV4EN3ub?!PA+m zG2|2eV?keTrv9@^dE|MhIZwGuy~+YgcvI^g{cLQ)?q{#oS#R`A=+z&GNyqBBhKBZy zajO1V4l3YJ-6>T5EU}cb@0S8O>yG8YiuX>ImR14OvqL=0oUH~`+k`PHaO&4*zvHRv z1)EtXOUpddz~Eh!ldMZ)v(2STg&%6{@jzAA);?L_(_b?-sMA8?;5iFUVA^mLg#Of-lOeC4M?L<>Vz7;<4nGrq>3Oa5{TMX+;T56hXp?0X3sg zaC6hwJ$}QFX0Cw@Pm~&f1HkDZ?-+aQHhrBaoYs67ab&@MC_z3p)|84FBx5geMfd@{ zLT(qG4zdM2ABC68<^AuaB*G0W{rY@ha~Pyz%)&$hbxpo?B-fP+ONb@Kr=ztM@iJy8+*=geXC-=w2Bxvek{3^ zTkiLpHLa+^FXL3cd;HKKq!ySNE;p!IJ*gQ0!tq-_Fw&j==G4Dm)3NE`(nA5&{2R9v7;F?2O?ElflG7mhCDJg&kYRcYAL=M zg{u}0R@`LtuoxTVEgwBTpChSInKLc!jX4HOmw?_xly3h*(Yc%~-m>^YB>DDnS3J@k zxp<+)q#|y19%Zf3X7Xv zp}z*NrH*FSEJ^`vJK)NW@GidngL8Tzd(`gtUn2mHms9^>|%_7h7r+Rt{740DllDM`L6z__5{ zRjJb|#4RUbOZE_8gaDBkuMxTpk?K*?7oEGLCyGhuzx?k>5BV2uJts=&+2i6=|GGWk zrBZ*AfacPiakx^2I<^0{%_G{07jbuTUKr z8XAe$xu>B|yH3yKSbHBweJH9&2A3QuX1h}H>Vo?~Y-rBVYw1rZq`8zc_tib8zpvM% zrNTUl-w31(tj|Re2Tw5IyT1+#3xkD8DJ3GNg})R2bwYJaI2X-+MO}PF9Tx-Vfe-od z$AsfMw_ChT2Crg4FeL=42lSY2P&ZCdQqtbUG?hnFCQ~SgQm?gD7ubnUGjw;%A>(1VXhFar+mLkt(=dx}r~Kt`*gQ~)c8ZBB`WVS~vJ z+6Js@Mhb(Dj;;qn&Qd7K6w?%G6pSE3{EAaQWl?^;>r+HlG_ORtdKdQ5no>7HEfMw< z0m|ZAxU@Yn%0^$k`rd;*XK!sMa`3x_VuTnAUxHFxAj?7@@2IP=#8O@cgbqs@3=x<( z<3T7Wi(s^r#zNCBXf{wDw{)Sy?sw>Om{~McOkG90Nq#HTn>;=T+%bNUi|8ZHw!B53 z1{(-c!;Fxu*=)-A>#u&iGGHN&5s!_3$OgH=CXR>1P08G0aLISW9O zU=CSFI__|H^Y|3s(f%UYC0p`-7mxi@@Nb{f2eg*>w|1k8BI(#uMbP~gC3oiU1;>g> zN>!ZfNaQUN`v)qsA?yy!OIZ6kuZ=th3J$W*kU|1lBwqf=)=F94He6Ied|)~uLZ-3m zpe?iFw)}MRFb09tYd((%s;rNtfdW|;ycayw2qZ%3Sh^1*&mM^p*_LMu{sh9njF!hj zEEXTdRKpK<0Wy_n6j-i7?q77X6H*+ZsA2r@w?$i(x62&m0cXZ39N+27XxX3)V_E7I zUZ60@g>_|k?-adEVRKbg;>ZHHW;Ek*2pS<1gPIW`8CxL0kx1?uE>698;g)byY1frg zO2GLRq%Jxr3V%^5`?r1{J9QAjW|Yr~=Zl7&44?q{nCv?a?kg3S^%jMHA8-a#beH*N za#ioof^~5#l6?kWZ5yZ`)Op^As^2L^LZ5`5h;dMu8+^KO_GTdGhW;fyTA5C zS6pBH*CvU&t+eM>=}4myH?6xd;yW1tlijp!(@5hG*){iG#+U4&8<3JD-_(G-5M$i? z=rJ*#qrF?9a&E;>YnI z(xrI=hR3{_a|$WKukcaNULL2r!VAj-J?TD3aUz@O@ej7 zmt*9|&l1DM(i(|`S^=>PwE+bfx}YNvX0bm3;~^0-ToW}1O$r4VVVep7OrPf=Mpv97 z03Foy*m&jdSz8;O(#Pp%l1j?>*f|6joJ%l`Anj9gAxV+pRY4A8R=m!$?W-M7H_sbw zFWwVWN_-^qG38?P#H0N1VTR1d(kNa4Lt$aw2q_3=i}K0ClqUiSxqH`?DYe4}0u~`^T#aRC2CmEWYV;07#Cep*NWah+^B~O=Y=qQmkl9GuT;i~> zaYY22ckb0z-adbz*uCiZJ$*joI<(}>z^>{{?)!Nd8fvs`err(x)04T|%%Dcc-3Rgy ztPPg2fcTzHDdO7*eJ7!t7lD<)%-VHJGbjTI?1BzGE}vXUW)men{4>4;wj-ic<~EXe z{%ds_aujS(`cabd2AQugL1!Kx>9qLNMbg^1J06)*Pkw4}6dvMY-a!#S(h!PhFsjHX z5fSk0)op9GS+^H`V3PCL!|`cfD$aeZ*l>3cL6xj(Lir^2j+Z^kW3`D`a&>DvS6+OQ z_wSwaH&2q2CR6fifhh!XB|aO~C~uK-fC(1<2DlXAOi};Ne29bu#654HzRJmnr$L0X zdL)WyW=BMDiWs6|;HNTfD;BiOB?P()+*DClus^nGIJ0Q3oEL(`ZJOPYIpoY(9rcKc zTTYCEB$Z-HD~M@>JkSnnEN_F{C|WKWHKCv6Rw)aNi0p<1`Y<0D2n-4f3+I$aFOVOC zJqbse{`#-K&MZ0l4XlqXwuuF&v$nx)cJaG>SpZpxL8@i{2`_eWNqE90?=x{56o31$ zT|a2sg#lgagUpoWqz8p57z_E@z*mMnM4!90I%(RI?#D}PwM9!Cxq6P*4K^**KXz-Msl3a*Hw`z;Ec^4~GI#W3&UG&&Xk7V7T+^L~r*G@*cS!E9k^!W{ zWM38QNFBSnTJcOM>O)k1e^PD;N2jrMf}Av6AYyPV+SIs21PBSp3efgP04*FeG=6wf zQRg51Go`|$Isox;Hqs&=2PoHn4ND^*A%mraC%sZNQVa0RRvS0A#$wH>jfwr?76cp4+f?P_VcB+E_ZG`tFLw$Ne&Df~jZyCCC&iIIsBH##c*s#s>xB_!b76K=vC~-G zESw-@Fxe0`JFJYQ!+=0Y))*`!L^AA%3T(WC!iJU*5+q8)(6u@PXYA7O6SfZ!)JwBM z*q4($qZZH>(1%;VFoFn1)ko$4x1Ze(mp2H3KO|cb^u>Ub-+kaQ;V+b_>#J8wdB{Az zd~PbCB11ANiV}N-Ztn9 z^%Krdl@Kp8(s?veEtwdUK~x_(QPI8i{BUuqg{ntokylsXhwgYlZ>YA{%N z&_iJ!2W$#&x`LTZLnagGvZU1n=Mn@)Z#3ZcCfzd>+9d8F%Q%@ zZ<3*zy&v`?L?3~WKX{sc^C)c`>N|_BKGdJNH-gOrm|=wCO`+@ZvuI9)%vO$rb7y%eFik^p;Es zTj1A4Th?7O=o30XaZk#P^M97@_*~N%G~IdN8l^q} zz>*I{&q8RWk^R;XBbTfPIb773wY3p)S0>?7H_N&>*w+@|Kgqqtz*p+hhF$CXgy=QR zU%g+Cb`}$VU_T+ddOt|&3sH+RN{5%~(>cysf!Z3HXNSo@ny;Xj7WfssDXR@UTmZ}? z{-b%}Ib|BZWLQp4`BKUafk6Q!K#~a{_AYvVZ!#n4iL1*G0;T8NK-+fj^lq-17;w>t zW$w`EoA1yV+)#I5I@j&DpaUvhk(nh0)wF<9pa}#x_%9ruE{__5IHlv|%5K#$|z@J%8MtQu6o#Zego|PTHYTdVT+?jF2 zRQmBv@<-)V%PTGdwUpRmBzx&gkB^QXo0#DK?4v>phU+I_F&rc?K#5K_>)j0D-o)rV z)xsZ|g-Nud4DRIbR&4cT3F0ix_f*oYnQ;H>Eg3*wIpKERrsav$7*^7EO3QK&W(WW7j*Q% z^3c}nk5S+y_zlb0rg>!=b^$9W1*u1GZBP3;?OeM5QzI4M!b=kwBtY(29epcfJ76P7 z(zUg!0+jMea=e>%kH>DRNNHee`~7z2)OO~5;_a!WbJ10DYm-NpqE?`#7Q`Nu6ZrR< znSJ!zY?QSR(-(p)9PH(_15Zmbs|BnWEmgtgFI-$i9w|L`6|BJnfiFGygSyan_=~+sETkZ2u|S7U?09s zE2-zf51EYLH56{xCSB;!yjse@+( zXRMup62zc^9855i>l^~{yU6;%;Z~2;&QG@GHMzCTl^kGG6}F?;lXY4(_i0$yzaWUi z$_os7wLx@25f(BPSF#NC`;BE!%j-aulyQRWq!j$l;|3;3p7cv(6mdw?w{4=p1zqGt z5J^BL!AE9k4SQlcaBJV?DsKg$gR}y{pgex=mC-9cVev#F5f|U9Y7EkV$65I+d*9ac$SKAhWv0X+R$P0c>R$K&ntnK=dEE<$h6 zxs!euWsd@GAKifHO#}(FG)m5LvYa&K7~vRzSHEUWdM}GL)A~r|2ONH^m8W>Mj!IJy zNKml^1@Sv5FvKQ{+*~J>xSPnZUAN-aqjM=6st;74Rshi|g3n9W@5s z2XE$fz$BnI)y>@9C>`n zRY;bpybNWJky=({LG6Qa(~m!wJ8gTW-NOXtr-`=x`mZ+3;sY~I7eCOeH0tAG5tfbF zy7_~|ENX7^PLI~s6vQ~R#!M}nOASY415g9kFh$2iMwUp>Ez+Nk_DQn1Cnr`y64XRc z4S(xnkPEX1p0QAB+lG`jt{V69M5z5_spo`h#`$KUFS@tE?O+iQrl;a*qW)o5;%(gs z&~gT5c^NL{6qk;=d6C_AwUjBhE^B9)QDOKMn#mtE`f1)B$AD68?1;YZZpkT+RddR< z5$E?`{`Xx4e(x}KbQ%h`d*cnEj2V9EHhrod05s+bFu7ZPdAE-d>m=(=+ICE8j-m1FczXnF@ zx-|!ETF>MZi5$=`q`J8)nDVOWm{{cGTOK?qRKt8BwDW>>3PeUu5($jnw1apFzDNx? zYi0&fg7%+62zUgrRct=Rd!JzzK5L9Z(w6E%Hw0N^J+GSRDcK6bd7=nIa2g!Ql9?X; ze^6Vrpl@+>?2v;xmH`ZgiVr3FsMI9U&km7MEFRU15 z5N^tY+XoeT&LB&w#ZzH0(iDTTFAJ=G8ET)evM=rTWwqY%tq#jZ&rC3zAP@?-xK#^( zYIa`yFQ=Tp%o|L3H$N=BA^Lw6g^*UzHBgfA@{l65Tov?+66rDvzrd-e9d|M0uK z?>C>TW2N;E-{~Z!H)zl&XkfUWoY-;q|*lkWpC^{h)Q+idxyBg9S{XPDWQ>x&MlT%Um8W*I$PmX zYnDq(7V}1izpi&a#nR5$MfZf!nO+(}v&P0Y?aRyxxgPxDzvrF@dIhh)E2t=_i;}ZK z#x3{@UQ`EL6%UP|8sH|eor=k(FjMo^Yn7*PN#FK;rEhw|qV8NGF~SqNTKsuY5&HGn z^M1mkoLzz%$z~n)E6u`l44~?bh1eZG45*GHD@q?8f5|3l5b0f ztU0jJ@S%%gG;KMJxQWgb6_}#?kdiTLlo!wV9=ec9h0cyD5-!AXxAKKp8BXX{M+Bf&>?V5-(m1R|kbYTL_hu z$qMR&X6H7WoWICm69{Km9`5d#8Mb}@vd`o(oB3Y&s{hM`Q2gbS-SRJ7js_@Gg}o^5 zVP9F|epn`WayxIzEJQhER}02PxTMX+nbNw5w=W0$L%@lDju_20bt`uQst z=8PW3;wtM7Owt$FP}^gCo!iidDBM=gQ&qIc1+J`S;briLuICTLXJ^n4UL>bA=)SWcPpFx z-!-wqZ?UYWOGby(gm&o8)e3}v?ckuGE-9$1oEgd73n$BklEL8~@U<9eavE5%B63__ zTWcKyM^U}G&(gRmwD1s*;BE>!DB}5NhGv5?{+EbJ=w=%(Kn&}zN*8VL4THWVQT0)YDvdNihi`2sfAew! zKS`hqxm`CoxnP^6dVEFMF*t&wc7(YQJ9mkD|`M>cm*y%FFQ;~mBT9ce9 zwc5b9Wyxsff1@bL_~I~kk{#9?jmOxScP%g$Or-Kcf>h}60mj8p6E{1(RYn&(w6Aoe z{Q7FwG|_wJsc^_`AtPZr+&m+kApZ4#Pr5Z1H4f{bQV_roUn>P12Y-ICRhC01k!RUr6 zW#PZRodt&(@WG^~C2^#!EV`s(#<%+SvcG%=d{tXN-rI~9W?K{Mb znzKnG$OB_4%P+<>9aaLaV`sL&U-e6@zB9SjUI(mOI0p`x8;6#iz`-gC$Fl4?kWWhB zHOfLTudRJ%TqiD|!iRTx7EO=#XX~b$Y>Z{yIsy2@2d2nqEv{#k;-zk4Xjf5@1sDc; zB_uH*-dnClFr#27&kH58QK>KM@-3nQ;6Km+Dg&ksq9I#Ux+OF+hDAjBtPd&TKs z>6jN)H5I|_%_;ab6B3anGIyZ=okthJ_Y2DW5_u>&tx;!hd85hZ(6v;)V&FxeEDY)i z_VC&WfIJ)MtwO;Cv3u*vV~n-|9EF=ZE!KY_Zty0!@1bj#-1~(`W}YX)E52I8V)4mU z6x&K@8(zOYdncxxOuT&vgoE<;kHtP0955+Ih(YLO_<58y73tw5Lyr-EPaC$ z`y-8*I}4{_NIrrE)ZtPFFegA3`g%4e8L7qoxj(2n^&Fe6seE}UTyfY)@VVTB6^@5e z1QYEMsuu1~BpOqdz-j__2UtofEURw(qq^20X5Jm0QD>&l>BIsTs@*qiqNu8>x;-#& zPLqX(0-u#WnYRT6l{SY@EbwJ}Le*!FLoy1QD1ImNZ!3QBFq3^t1 z`Zc>CL$fE(N-#I>l@h>#!QHYgx0+4+2fS-2R~XA@5OK21u1EC2gA0`UlkO^`azB@7 ze+k$A=C0VQS8s4BMYqJpqUtUW*Y}25c-US?kixtZJ+}tCTSp##x3E`}Y?HEYvdc0Q zqW7a8dE+m$pY!lr*3dl%-_Zu{(H{QU?n>vNB5U@+RHUff^Y>BHV= zCc^*-u;N8!-QqU;*F`t8a$`i3vN+)F9xOtrp@c=qKxh(Y<6;6H+-F}KNOrN^7iM z#iW3xYSncCjkV$s={cX`_zDL68k@*r&af;Qur;Mv3bM9u9g6F_Sa>e<8 zfe@jcHlAJ>*~&evNSS=|A(^m&&2;}XQJx8UB$MRT22P*4ce7}^EG}QK zV}gMMYOAo?>HWckHW)`HV;*f4gls|35dZ;|m?(4Z4RortRQzL0YPh{;fHDo$c|X6I z9h{%!q6a!+hYCFtf!!x~{P77x7)?!^CX|0?y8{PIp}W$alUIhaR z)ui+4YHNwES9Xk_5MosO4pRZ~?tW5U==$GxI+l^whcsF@n@2C~JcL*jk^cXV$Lx zVS7|>+A01NF+Y7=H$u3GPF;F)r3g772_obmRS=(t6KcIr(szbhqxWxL+L&7bpzEM; zjcu(75nS{75W`?G>J7_|vHhWKZi~r^0kkP%3P-^d6hu8d-M8!Y(kPug#cq~lL24v% z&?_(IHOmMJ2RVe{Cg&0SzbLu8g^!Ke#N8fQ(=IPN<>@IfIs)+@E={}J%2uyj!wKc) zAR1Y6s&C>X`ZbQU@Q)IB4Eq^yP7z8+t9Avty?s~6E-HIjf+u)9v@HxXT3vhVHTMxo znm-pt2GcFZbzH7042LdXIGDDxn5@fJZzmeU>tp`MPWApp|5^_5*J`B~l@BM+Xtkg$ z2ZKNjLcZBEb>sbMU)61g-!&YH_U!{6Vu6S26Pk90Ye(fyCnIWIT82i_Hfkf)2*d}* zpX=OLu71w`yU(_7C9YO$++J<<%^Vwk@2Fw4C!b?$n1jj-Hj-bW<0$TY@NbTFEs;3=%p8RUU~Ius_sR1*%HZ43qRW)iM~cng9g zz?J7;QMNIH<67{tNX)=ckRnTK#O}b$%Tj)@4a=4TT}I_ib;1@qW>ko>boRL^2Q6gQ zNhh!oix3(n+-v8cXeQV&`iluUkyt?MDnDX`V21F~XbZ6(9q4&I{Wqh1SN)UB zXJH=`m?0%mnDa9lFJ{wBfj4t&a6Xs0adC+fVS<^8=)l0mx_`%o0N9xnHJ`y74FUb1 z_T>jbVzx~N5+V`Gymm_U<6=XTK~fKxgb_^yZG{ufskqS}aMP7+52Cs}~0JaO0B?6tHoRc>m z#?n@?+`6eEwl|FhSstmD!zJb({DR;q%w&-2i8>E>hEG79U>!NO?zM1*&lBd+l#17u zFSs`hkaACl^U#%`%2rj77q~AJkrLPN@bIy9WhS`=9)+i=Pg&QrT4l_*WLdUK;26s$ zi2>cGXBGo;&~3pk+Vb=P-;%0TKzCeaahvBAdY2$X;fOLtUQ%w&#A zM~JiF{tC6zvG}y+o1X@fA8MZOIN10Awm_{r+s!)Wng z?t~1bO2q9RwU6&2u{j8b18_lVkq0IO00s_${Vswckcjc?Dw>qcIl|VRZ)L4hb3iQJ zb66&QNYX*;M%h+a?=?qJOrg0+=#hkl`%22i**rboe(2g?ZQQjg6ct{f9rgX`dCl{R zx|C98erNoP!L~FtJICg^e7SVepbPtwsMnt5-?yCq4^Cr9>Bah7zeCMw(BYPcOj#a0 zz1z3GhemzWcj@z&18o`<_uATD z+sYjL@uHeDeqQG{Y?+37A*A#jqB)uoj;vtUwXc+vmMHvbkCP*J9cWl#wGN+?M+%!3 zz)?&$1s)*%ON-zN`({~l1_TUdq$ac|LwYLcl-X(*)flg?W*E<4E%5LhOpb^Kg233_ zT`2l6q_3DwtVfq72o`t>IYF919Y<#%pq_3$4tISH)$RzonE>T){*cZEuZFgv-MC42 z;EUTqDyR|7XWUw_4HQ!_-CA39=}V{(B{L$nXkW%?u4zY?>%#~^Y)zKM*w#(kf9}I1 zmO=7rAuS4t3garZb47XV)K!?H)@jZ7eqB3@+5@dvq4C}2za09TLj`7wEsV^PvB;=$ ztvH8#F3=|$lB9?sbU7M+jzLS)`Xz$eK;tm)vWsz=-oVc9U3%f9sWX;SN-x~OpW!m` zJUYz0#L%(bVG{ro=tk)dT`+n`!LtQ11Tnt-!IWoCp#Zgzik@Ih3(80gSu@XBNSQEA z0TN7`A}2NziH0$&)VE2k@mEZpoUo#J@!>ngAR;7aiVYXaKyfVe=xqMIeS^9>Z za`Mg%te$qcR|jtjX(V?Bx)V=D3$(P7?GB2AJV=R2HXp4c@x1IsbmLk>lH!y#_z?&Q z?HZx89&*!sY}Q~aSiIt;A6bMJ6jlRZOaUM;e?{aG$Mb9BGc zO_-K~`I)=6bP#%Q>td|UN#{a~3+&vhb0-w@G-m=%V@SyKi6p_$6>=`?5SPexy3)Cx zg{vTgV(rbGE_;`Sn8d=%q5{<4IQAcU663)|toQJfig7f2`u}9|_SckTbve4s{Mgp4 zHn^v3&hrq}-d(w0;9Zh_wuY^f8C{!aLPTVYDfu?(qtnL<^7x@|iodVT+6T#f@}DbW zLU1BrPVxblOqJkZrsQ<)9; z@G-25Fd~e)Gs~%V;+(?)_eyaXRyV*`rObOkcKQU1Ln0 z(cczxW23$vOr4e}DWn|s_`JWx$?*Hndza0*8u7-vHDX%M>wwtfHp;aIUys!v?7K%b z;@b8LU)?@VN4}%`I!~YYPsuDMbItj^U7lic(V9Q9Rf;Sf*y)$19FyiS7f1gV)$|c9 zEgv5k+xF^HmdUNjxgr$jkHehq#JZelRc<|A8?`O<_piT~t%GP`SKEu?=L*J^b1%`He9hIx@Mi;W-l4hk~fq1RZ~5U{Iu zO`G20&YO)N_B{RTo^-o}h_q=|SnR5FBa{$;onzZp?mF;ZSGP1wZSgSg`264h-kCO{ zEvw;c*shuYHSNTfmZAquH8HJb8h&bbn&0P5No$I!e$U)jxWZ7?Fpo!=+z!GE0*z3p ztySg52-3qoW3`O5)SccT1*I>g(uQWG?MSQks<>$T{`bCzGSBUDvlw0-_9?nLqAB1| zwS{*<>6I+!@fmto!rMMpJdf1P*`IpFz%Zi-}350%(2e-*Z+Or_^jRMEnxAN-YSl#lRu4b zR0#Rz6td%B-dT@YHXKnBQ`* zyklYosh8pnv`fqg*{_5&fWi7ZN!}WVOL>_#EgFf{=d|@6?+6LnsMa)h=qmdFJYJWt$D{55?RC1%2{Ogm1_^@b#nLDl6=YEvgY_owg zK!1k#hMFigJh}P1HPMvEgUw@nzJ1Zm?;ZSLy~cEJ=)I(KPL1*Yu;#g#`M}~6u&s<6 zXetv1I#&&av8-}$myP39^SFGYQsYc(><6e@C}S|6!!k+~Gm|X3UR=twRpJU+6I7iw#Tq^T{S~$( zbeIpOk6EWW2n4F3LhfUr2UZQtaM;xKB{n_y$Dj(VNH~FhU4BlLPLoZ4q~GoX2oO(k zS37gJ;t|`zO{qMi;z=e{16_S7dF#qH4(oOpPGur%* zf7N&6UsFO=rvJZe{P_Q=apRif9h(sUaVo^${=e>M`Ty@Pw(*Rq{P@Q`|MzF%On~zL z>EHh!tUmvn4+e8s`;uK9t7-lLM2Wxs{NJ)$*7 z!&l)SJsF2IS`J+#osyJ`)tUo_G1IvaPkKfOr4F=1|jc#$Xn0yga zo&2f;_(SCHdDHG{Clj@P@MSyMyLLS?7Yz(QKnqqpH2BSKd8{*G9vF3T{f~e0lR+}y z{AYLGNyGT^)yogbzFp4$e0HpFZfy7WjFh&8=g>}rV+-?T)G>%eu43w^dt{{Q+!f$d z!kc39WUtWlzV*$<^w#oGhv?1#OC7Jd`uZB+b}_1b^8Puh)zk3_I1R>@2Ce_upEgQP z=9`{dM*a-d5yEuh!d;~yH8TAFl9Sya2$^2&DJ`wAWZ9Cd%azR>wkRxKobu-FdmhZD zU+Si?&g^%iWiMZ)nz1AM`j0;@A8>X3UFgxU#pBDYM*+wDFwOOJzUkMm<@xWSmzr!~ zj_cMsDeXjnGTMDVoO8SdR6TU)MG!2icKtIt|M6)ZS33l1fpm^6iUd(^PKu`aIGk#b=Pa{gH!R1kZV#qRPsI!77r zeXS^SDRV-aL`B>ydN}{2>#^B1vqF7vJ^lB;C!0KU`j6k>^Dz(c4df+q;OmxFyYj>I zoV@0H4`PbP*SZ=s8A_30h?_e1>fgVBe0+cH&ss^tXJ>T(Y#XKDWsir;ngLzZ<&194 zYxa4hEE%BVkmF%+*2TCFNB!HRbJ?{%=PtM|E^G0qNXsaGMoyli(KoUFjXb>s)mvpq zY|C&)?TkH4HKyOb&^Fpt?@&p}!g@zzaX*f>uAYP;>$2f>VStgaKGB$t8sPx}`I%`f zu7$ScO-*SN)@*I}`2P2VONs8xV6St$`qh1tV9fnp+VlJ?rSnU=f7#ccE|a6wy+P-p z)!-B6ogia@YD%q5M>Ed~WOCPZ-nCVRMiEN8#8VYc@XL($WCq-GYgoQ>Hud zxA!I1VC&CHtUtC{ROns&acYKUT3yA6_2W9by3Xt5w;*n{`HFo>*(K$dZ7iD{lbjw` zytcHoh}anwebRiz4Z|6;cJ^Jrc)#BGU-B<&`gj&wh-P~ECe1i!aKV7UPg(!yqu(K=VmA*hvWK4#rmiEG?dgt*+x&7 zaXS=M?eU+^YV+ZrUbLOHOpJ@zrJB-dm}=?wrpF_`A8=}P(>-B-X_NBvZIe^LxyO!m zNG|GrYr6(pjn$?s)4r@UK<;B*!-VN0-j)rzxe`!iHsb8xrVOz zO4$WB&oJWX<8@@hQXk_-DevFwHZ{o(8$MER>idij2bBkm1}!IwH%8BjIJ%EBr73%BWA zP3{f*hsjFEmtA# zv_9qOeP+pM;GFMGfn6l-W~Xy=r$Yy7arRG7SG5TXqwo18#j`)#Mw^<^+#W*~!VMYc zJu<^G0#*!RRO8I>QLW~w(1Y(9daQq($~SCqWCnI`S<63fUHjk1F7N&=c1*nF)Y!IX z6NhKYx=Hyia&PKALVd#2xHq4btE+8vj5ltv*grTnFVVfaIMTUAMd%lgo-lPay<}HA z{_G;v@p_*;#>WOkhZP+Cz#5~BRX&cj*?~+fe@VKr*K%mQ`sdyE_cTZRvLO!5(E7mU%q>%S~aKH zHe4#>Mz-Z%TgR6st=}f4q>N*l2WTI8pr(6P$i^|lPLS{yDD`_@FoFJ8_SiAn*e};) zAUrmgKZ+eQMHdUhKO*$kO!{2cw0h^HCr#z>UGx}jNt9oX|4*;(TaOz*v`wQkJiBF; zs_##O7C)Reyx$3}(+lL0)X2NykP*20z>BJ?2~3f^6XP4I7cW^-SYN+pBpRL)+qagx zEBbWr;^%*{2)IQy^<^4zpN3C4yQfaog;l08X;RYPe`}63j`hkaE#mdwfK7No=Fkk+ zZJG?kZZ5C<_91R|P`JwJJ?DE2PMtVmLY!X9s*_fMi*Fl!3Jz(V6`ac^*ZhwOJ(&Fb zRcbd?S9j}X8xP}<`5L|-3rt#0EesM ztoAbxIDZSockCd~!v*&rWqthP)dtNW`yi26q+T+$Haom{@c2J1HTldH440j^KYeVx zo2ERm@edh~9z7f@pwT{ zQ!{CGb&HoCare$1`#nz=D8(}*TIJ*8*jSQCPJPl)JB={Z&{(=#xv&THZ{M{#%izqs zzMJ!F@|d8^A-v2>etPklDQn=an!B{Q-YwhJ-CZ$#+pm^(M;KNyTe!BVPvYJI9|!-> zZt-9KqQQOTFKxs34m)P`XmMc1wY&=McV&~b{Lz^qS(|NaJQm$t6qs?-=%);;@V&0+ z5E2XWulEk|N%8b~y9CiIL%pre&R$_29-2dz8jc9DE-v4QD@6W+^Vr?z6_34g+@`g; z-_Fv~?*{@8?ek9T9FLJimHj@?BHxVEZ&W-PnvqP5Y{Dkq3E7Q<+*8ksQ?@^fKIOGl zs=l&u9}n?G(I44*TF;BCCrp|Omvk!QCNRl&G5ap&KAwKz)u5y~C~)HvdKCLzxnh?8 zQUN&sc2?;o`SZ6SkS& z53kFn0Squ`2i71+6X-HZYq)wq1TSNh84*BOXyn3c%gt?eUNjC#`~Fh%sHylg4$bEa3i>nABSP-c zdcMGp+AOgusT-_~kDu$0Z2Dn_itp$tr?0P7UU_fz!2iuV>$2*XwvBs-?HOj7^T%Gx zB^l2*U(oWJ9i6+is7eKd1w-OQ8|^XzH)r9GKjt-jjxT*~wn8zz>rqp6-N}Mf&xs!q za_>RRgJ($0yvml}HpE%S2U>J_2j{ z3beisk%1ML7%JY>jvZ(GO18hwnu5RDFxi|3Z*p@Aus_jRZ%o=|8s9MMko!anEf?n# zm=HX>rVj7Z{RchQE$bK@!?KGfwAe(4y-a-xNsjb2=b5QSi6-X>V{#5XFT`wGujuH5 zS`n{sUP9$Q=XzhFI0hfw`eTQ-tk|t7IX>^l=Ejk_7K&4rSvg+v@zEjqP^JWD1`PIq zqw&*#llZFrSy4x|EJqMyRc>IeRfc8wXFF{Oy1l%;)*3sU+LiMrqUA}5;lEs||NAc+ z4!xhdYIm20yn*pg)t3UWjqpB36mt19aj&JEG)5LoTu*s>3d_mqN@a!@XFS!Pd@*6~ zFuC~WnsRdSjNQ*1Pb5{`SoKrt_@5_Scy*;*wrhP0^Kf9?uAQaz4JDNmD3GLc_R7o2 z*hUZMJg6^|uDSbq`=iFVO*2AruCsVl;R$y+aJ9;ckSSCkN8+z#+-s}SD%iYP{%BlP zRkz@sLm#f+Rp%@zvi2k6!@a+@iT=zjv0&=up|X}aw{ptT%WC%yldWEOdNv^<+cLeh zw(@|}`CCu1qwuTu+*`JPg+>h)KcHZ@ijph7~{T|72J6z>!qD>jtrH^x?#q&3ch(w=?cLopX-$ z-Zjl$$5<}o>B!3dUUQzh+C|TPY`lNkr`|m}gZmNymOb()nb56sr}0Kty>=DoWO=@i z*d^npdv*(U9JvK9zJcD?GNumqNG#YJt0-$~x4-U9c5YFDw@bp;uW~(lSgc*=up~AQ z{(M1QLuHdFph33%mh>)2igC~x(J4+6e!y*g&3CP~iFexGy*Xtu^2q$e`H+i8#;bD> zI8@K#`fxRfdn!fUz1OVCe)~SZu(0s*>W`GwjwbPSvP=^VgYgZ%l~pQ*ZR!FgpFsD+ zhn_Z#2>M?N?-zMLbI@P!)W|w&DssD@pDCP;*u1n)uLohF5t5bi?yD-gl~ER-innVH zK^(80t0c+aa~{$JWK?zkoOB=cC(>`coQ9~l^iz8DGqfctOd`DdtkYH@tZ;2|PTZpN zING!_&$&TrTHw^Cb9S$$$YJm*yR5n-HKnfROtiMXSM^fjq2|(<5xvjunkX-KUp3`t z6$z~wrkH#`rPB*243oUfi5UY<&fV`4pZ}&dtXliNYSd#j%J!AF=Y+1cUGuYj_ND1g zJ%^3-XNvyQ*+rM8PE0F&-nnY+DO;6Aq21u#t3u3r!9>C@nWg)WVkdacnIa%DVw zkRPXIRkkEm4pFd5WB=8KPffb-MowQ7U#B0x*|AmY)!5+Gk1I+y{u3Q7WQBd}qrwlr z=RYoXD?Yv8$UA#46DRx2x0AX><@O5RxwEi`!{I}c;_;U(G%wyXx?LI4);J$P29cu{ zE7Oe-6yGZ88tb z-tY7QzdsHPui10nLM0#cn=INbSvpKMHgCS>kWoWU{JCxN_VBkAnm5X|dR<$7e2Mle zxRphV{J=ZjmSwca%{Gg7dK=-nWy9LnC9x5^GD_`!@?G>WXSj+l5@qj5mo~bFd_P~w z(VU?os%?B*R=dHyTs0-YFc^V^%Cu?GFq6^85j|BePfIEGcS@!Vt~qe|U|l(dWJzzC z?vG9!Z|)ho>msK&bnOk}kmzd_=MOx1zBv<>&;HVHZ~+)%-4L3uVk z?5|}QWQ!G!`X)9L{pu#C>5WO;OI3{U7rwc0Zd7Ls4fB{WIXUUzuCVlNpU{(gAkDE~Acy`+rkHb+k{xnQn{BSflCCkby$U&HU|)2kU} zf0f|~Hs9jIqoNPXLo%~-@`@>)&F9Y}dN1bjqk?#B?7d+0OD^!@;#5ZJZ}=^hpFc1B zvn|)Xp+Ch?+U25;4P#U?RZ2^T@)%bg>=#v9bAYE!wHEnKYhuPk67;vR4*ZVHu?hX% z%?_~&@9)CV8**~!e^=do{myqFx#6hkUlnx}B327B;Fje4QC!p4>&S^C@%1gO8D6bN zXHpuC@Q|ih;k>+wU>D+;zTO#SwUtdy-~&s%i~T-I_3hh`L*tChg{hejvZqJCl^Glz zp3DL^UXHy^D#Z^yB-~9HOHcMMXg>B}*G0HI_}V4Lvhy-5JeYg_tid{c&T*04UVRenydc;@6k#UonZwU84oO5!BMJWZJa_aObm zX8+(q%E0d?Iywb7l#wDLJc0p$P|5=+B<0~(mIacNNb3_PPewO_A}RLIYc9VJK{L*M zG|QRHN1yRS<@@f&@6WcFyEAi}OGmsHF`m|;88h5_{If&*Hczj@_GQ|U?HBL{RvJ6D z#P*=Qj#AtVOr>EKnOj+%_4C`Vn6DljT>37y&GoS;q?I6OZ4Fx(o0UkZ4FnOKt@xqN z%@iFnDkhHLl*$4vKKoecHhXKoD)Pw4)ub^YMU8+;v&C+!%+5`F&!lDSm|S8T-8OYw z{GRBh*yLaV;@Guec3p z(NvF`;=wetb+mr?`s=ynG-GVjW^=`%Md61NHM4Vb=;QyoJ%H1Ac1zf!NBY;(_qeS) zJLzgpDTHx68VH7_IlVI1`x*wnBGlu|+1X8sNI6wUCm_SBzjq?_7DaY&=B?}`Td$tR|jV59v`%z3|~=-kMZ z2dXG_9bl^?k3&5hdASbtE(ZaI84V8&NX5Y*mz7=xfHdn&Oo}h=e!_YNpCw9>d-6Ns zRfua4BjllZAzXLJ!FkS2KbyF;OhWX)Er7!Q3^`czx4681IT5s)JWgSfhF@v|a_I)g zD>QMta>~zbzd(nSSTJfp_X*nC#Lr>Hsh11BW{1%-W0xq7r4ie7iC_2+QtnDpFHMbq z@#00A_6t3u%Rl<3**H2bL9JO_jX~u1msS6u*c?9|lbkkXc=g0h@_}(W{%MKX33I_r zMmpIJ&(N%DXhD`wTJn-6Xzwm}NqpSY8!6kqX^~Sd{!E$v!qa=Ao?cp8tA5)8T@yM+ zGkMB<|1U3gMqSPe4j&R+S~{m-g>y~%G1bc^W?Str!|mj?xqq)?Nn7VLlh(1XKc4$| zW0ckTKSdo~J!XYdyjDx(_A9N;V@CbB+1`W#p(iaEhm9a{v3H1xXa`+d1KSow8ePle zQ9oa(v`Qj=3|M*sd| zp{{qL=81&Olw)4{a6(6b_JykUNO=HmPW>}15qG)6ZDlS^(l@RBfS|43M=iBdOw11R zxa8;0?vPbMzm`1Aff=_lX~6B(XL9zJKZuB!7oBTY48^fEV(nRWIo-_Mk$!OHhob5R zG>YTjy%xYIiyyJBAdu4J+z$(jhcqZ$WMATmh~IID_9xSIVartQ%bmtryM<1Z^1IT*P zQo8|(kqCg4MMi3geiJQzx32=H`0h0FOP$^N1qWUTi%X#&fT##zy}bJh`Z*vtQ+Xl{ zTzHC60@Tfw0=FU46NT34Hh%t5x8?gPS`Er*2|_mERsE#3aVN;6-;$$3et}lJEpy~x zJv(DasSU{7*TBTbpJB<;rI>S4(&{bT>-EoaHb$=^;M$|Z%^^&#h#f_#xd#Jh#>BbC z`78^%O`OZax%X#E1Or^94j)VmAR>`-Ijwsr?nX7W;Sm&kL)1nJgW#%f{Am ziTy6mt9mo;8Yf1S4Ngti^Yvg;la|X~v}#)qG-|3ARXfnDg7f9rDX&qvR9GEo6Be3O z{+@OwCAT;~zwfOQQ+0Xu(x9{K97-bm@A!Uq-!H_L&YKE*s8H_b4W!Jv1 zA2gLU+mNIhV`vN&8ZFvXqa@W3l6I9O?WMiX@A=C0{oeOAGxz=1{m<=jJ+6mApXL32 zz0c)1kK;J&OfD*ySoTXjNPWZ_*>oa9;pKKvSIJ zCQ&inG%C(8YjB;CqT*BY$Xr~HBh18sknsQp?tGqVwrLctEfnZdHCiNAa0$%|4xToP zvg!BWZpH_w#mVXP(gu-nRLDz0J-|=eJz4#4Z>Gr2YmI9+Yxxs5v|_RDPgp;kmzV z4!O7W-LVI+D?6U(4alBL5U!azU=g>O4G1Xg^7}9r>Ui4Xp)>jX4yg1_xU1tgLS#U| z7qi0q???5a|CEQ)vGgv_pW9u`P`y_1$Cb`g)oL3(dUIaZM6aa&-5g@P>HN4$-r42> zj4W#MwA~2msfLtFzC~!_f8n85`8oM(1~^jvf3eWYmczd>|_>_SKCqS>)WNw-~DVHCFv~rjr?h}XJ3|^%yR1ZjGf0^KhHvE4|&%%CN8_~oH2Fi z%lwzWW>+FFUly`nqFlT?Pc<)3lkps#UAcAnxx|oeBivUox3O}x#VASnipiBpBR-jU zTE#Hvr2UOmA?vTKn_**ViF?K%e)spZ{xESn7To{8{&<+QOlQeyi zd3JqU$mJO!W-@(`ZNI&YGRbJuPe(;fdt%91W*{FnqYZuRH>YUdx%0tA z`(mrB^@}F|0fCB>F=@DQXWG$k+2!Wq`Pe(mM1QnwylirxPl`h#JDRE`Q<7Dv48RpJ zSW8^m!QEt+aoKL8&{r6ST~i2;W6tM z5F@j7Z>?m_Z+jMeb=b{aiPHw5ddDU?84smS?aInF*U>g@_o}QmTJqG;TlVuv405YK z`1hmQ8Ou=}o_tC##4z~D{yWikn~!eMcp2eQ`toH&sh30G&>`wRi{e*fh{m-$n&Z%e zVI~&qct|76VlH=lo*t1_Aq#Q<(5zi~s!BzEqg=b2%=zY(4lS%!efMtcHRIy@bBw2C z&%Hi>>>sbpgfC}czC)A_m5{xQmV0dbuZt2IR~nO&C~4^Dwrt6R-NlKDbC;&wmdn2_ zm#mkpo?n=oa%cbkJm;N;)6-cr;|nY%XL+*1ZhxMA->z{irti|mmf%_?aQTy}u8NI@ zcSDR-LfB|eg6(kQ3+)*Ewq-y~zSj6j;b#6|0PkK+rPYCarjU27s5aYP_N&d$e*Zzs zu=cD^iZ%zX`>eWi!3AC2GK;KR;chd0OFP>d9kl{SC&ee{#8B^17Pi((yAr~0-xj#M zg~dt2BVD)b;$C61UEQ4HJET=wabq*xAv{~eFRm#aKO&g!bV=Jxu^fUO^SeT~ zq*|eLBr$+D*om-?cRX5ex-enDbUrODZP(GUB*(dcH?|(#I%NjhZe6%i)Hb&|KL6nL z;AYr8MQ;-&Ly1n#A-VqxU;X&IHYY|H^SlBUb#!%)v*@^XKT`#re#^G8*IWuB@;g2_ z+iC=iuIX?!s;jJzzxkVy{8ULn);2q7a0P+kUjKo~9r5iS!UBVHs%yc#Tf=ueeJyqJEX=waOb8`)%O0b+WOi-AhJ)Y0zzF&X+I_n7db{- zTW{`Iw>5PR?au3NJXcdw!w!%gMKqy2eLVKPp*A9NtHTl)=%wCKi?fTFFr^sfpMwIQ zm7@mAN|C?f>cX%b8xOXdA781ARviJ@a5XpO$R@f_J0F9Kq3b@KMQsnI$Z zWtuoUxBCrq1?MKHT3avVGp@fuFNU`-U;%w8^Jxu@6)N(bg^7*;;vglj?(U<0#Q2+r zo8O4VK%ny(XpM<GK)MWPg_>~{yp%^t?9>BDvOPz zEVI4aILqK1qz67YvJz%FD7xw&bq!X6BX!b9g@O;&<`HJVRJvlDmvBGWGz#&vNLvrC z5ZXFgL}?5{XnuPo^DQiJs6uyaqP{|;+wr{87Jg|{hZ`RsFq;>`c~q0lC-seOT4XRH z3&8QHV>pJxd4*rSX8I^P0l*N@S!9T*R>)p}&~P&VmVBu{NVBmmcFSX)R7(YfMQI4& zDs9`ZHh%f{u3q9_xLA4Px83s{HzZd1+Euva)!f!EdQ&;1J?vhse?(iTd$;ZA{De1- zg>T&T9W1-F^IV+g9jes~G#*ee|HO9%n-=SK$1m9WQ_HvDNY_aC_1sGWM5_a z`h6TSJ~#!a=);n;0w&+M&nG7C@Z6Sg%R~*6vGWt}WN?>ggEx4O(AppwVw{d+#r%gw zyXW$gSJ@zokNc{#T$NNM$>Xcv`Qj5$vv&^B0qu>Vj{9TGP}*Dz2RHa%TR!#Nc5CZ1 zn2cgq&Av*Ooa&6Y6fs@c5X85?n?{{Xy8_ReIuCq}laHQ$<&`H0`Ra3b1%X4Qna)=8 zDfJ;Td9bE}^8TxlgXCoTa`}f1&VqH$$CF)jx8rgf2WZD@R8A)%k)wu9U-faKfb&F~1J z_1(=GgB!3P1xAGx4Q=2&;ZOAO{rY5$oP<6{d~2W~8dKQ9V*A07jnh9oHK}IQe&Yx6 zv@n9BPU1RaUfMwp4G2k?Ow2vqm#nv&vTf$|_>R@cx#u=}*qmVpr^B{NZ? zK8DeF;cYT7m~?Ihuvq@NYfMqwP#uYNLkv(dcJg<>uA7PlD}7>o%y4AZt}x93r|!hY zuB5>b=vWF%G1_hNoO!3=)bKxPkyQ}Q*{)k|=B?8G(;yPk7Ig!zB! zj2zex?Gt+zkBRj^!V8|@n_0a-J~s9-j=$;k^|xcJGj7!xy}T@OaL8HUvu^6*iKi`N zLR&iAXf&go8kM)XHGhUs3r@tnf|@z~<%koUU-83ifKC$U81Hi02@=vSpGtxal(^wH6X1<^tGVrq)WNF50cd zeF@(2Ic*A|BP~mk`ja~O5FE=j*Vn-|k-_F#Ac8tN`n0@!-I?*NhiLy83!YwPi6n8x zw)mBkhWGxN+Y%9xBI!((2rn1jjoz7uz(LV_+C&f~&aF@dJ<#b))*-{xnD5&77eHAi zrU+usAQ6o2#hT^GzKU5;gABtMXbZGxF_Fi+lL z&GkQ7iBkVK%6r=E&j$5harNmOOj$y=n(W>CXzK~#^!Q)DOqYG@PO)%W3Al}po}nVa zEi!L@v5YHTL?i*$Jb8e2d~E5&U@*Nr@RJ5~T5vB~A@sDqyAdYh8!JLCD$^1&fLNJL zdxFfpU_zs%4t+NFc7BhV+@y z++l7vf3Py!)YkSVEJ+@0#F>Z9kkC<3 zOKJpFkJJuMHi-pR8?j~&_=V$sV?&rVcZ)pcWo&6{ z`zLFvTN{Irl71aWW>OJd;#JP*>7`*2oU@xcYg!-7pzcMMZTY&M|HAZ zThA~h-={9@wb~GBmmUI&m8^2%B#yRR<%}t_)AKbBt$ll`ozNLdVjhtf2=#m156zQMAl~!4m{k1h2GVA8=Tx>EL zg;$FccZRMhXwwgWUs|o(Z)BMu+q8WTd`ewKa7rKCki5t7RfHMYg)f3d4ptuew>R=# zk<=#>PVci8Xwu>0>f_F}U1&;peym&^(F)ja5VSWV<_e?(`vm!?K>G zsafS?B41oY+sI>zq?BU*z_>b}C0Fi;PkT-ziBvJ#yZ0y$g^Id*W#}6Ie>oGboeH>z zz#zb8i5W8G0g7P)A3+ptRW2_^NoO9(?An<0OK#(q#^$}UdnLmcyt?KH zrxR&BKZjWr(?9%;n9d=vHk6n8uRqv&n|ugp&L_Zr6#@TcgBkpOJh-Oc#fvy!|HM(} zJ`X=aHO$ZQNg`KsS8#6?cipJ1IZU#u2emjrZWRt?X8uf(9vmCZ#0JMMg;cBskX2*j zK+T5@t8gf(Z2_$xqog#Ms{4p82a>BIAb*e?>|GcoatiQjqy35gwmZ1u$(`vf_X5DP z%*}l;IK}&b=>Z>0MIH-N${bD}wW;va!VUxq32PW;7{&s|54^UgkGLH(d^-$1|E46G zII$u9=Lre4;17qIUR0)JVd^AK&7O-{{o1}x|0S;)1cAUoSxYmE(~S^o7UQ$CWBc}` zfv#zrkFnft}?? zfOe_=+t@-!I)SrDZ`r0J&61u4j0jne#erB7UO!XXFf+?mv&A_MryVhSgSI%Dbj;P& zotQ|v6}KNu<#$c?&ZZMz#Eh0j178qlT(z@uwn;KD^kfi6bKD{yG$g3QRU1=>wrUGW zx`lh=IAE)S`{Ajj*qvK*-9$MlR-`I;!zOZXi7BdYs|d0IK!$1bv27xoB>HZ1PvezW zf%1`W6FSRh^7Or~k8(L~;bLA|uO?)++&`FLG-K)%BMgoC_|!^YjXL%+B7@3(^i0Dt zF5#D_mqk73U*y&@UW|CwO@+?^A)@50zII7?mNtFXMbU5Xt+`FOPLtCW`q?MuEsA&a8 zBDt~bHL9SsH!+__T<(7^deiuB4fI0UP0nc~r zFds#)wvH}skHlW7R+b7C=RSW&!pD6r_th&>3eOb#EXE4Bjr|i}2Izsi^z^7%JeIv_ z6-4#0;%X8qcRbC(`SHE-hGQbe9vBne3rHHAj3%Cjn4*rF+~G4}2PsY=AD!vMx~UJA zD!Kg`Ch6Meppbmu4RiTDd(z^hoRJcHa|uWZ(528@s%6e;gu6r=0$Vc}a^?x#ABZ<3 zpMYSx7;#hK7jwd%Jo#R%LTw;ehturfJTP}vdpKpRGaZYWQOv3Cx)gsh4yboSJ|JwrY|08O%1kZZ#y zVnqU$mQaT8HJ(o|&kt>;ytRmL^V%Gef;5&TaK*159K$K5`v=WB9d@*nI(B6RUd^fE zV+d5}Bbq=p2yj)~!dr8jKelF!8^t#yQPCch@hS$fj!y!DO5TZW%#?nL3>nFkT#(DF zAI6z`=?v}Hhmy9DXOAxN6VHXXbL@(kCD*x)?{MziXL#UL+vjclp`>!3)kE&nVi{4< z@vtnL^m|iaL@C+DpFzeln6Z?%0K1Ni#65*$2X4JwJ7}`vNom<- zOXj#FoeiM|BInUxGc%-BCInJ4@Z5Z?;QDsKqoi8r>f#YWXJ{fX!x5h3xANZjcR>j< zK?&=Fq<&1E$DZIp{#eq2t8AGvXZmW;1llvw@vwp9je;L$1rBY{^g#~tMSvbeVkeLQ z3fC}&?5dQmrk2l&|4wN>o%8*vZJdCzrHow!ke%*3GiGa|_rdMVQ=6S3Vez1eGz=q) zK^V9d5DX;_d>m3PA%j9OKrgj`&GtKOY`FD@%<{6{=c&k1ho0XN-TSp(@S>iXgIlc$ z$pjw8^>t#o&8AUDbd##hyYKnbkt(5=6LC^`5}7;gy}P1k0t3EK(7^-*aO>O(iXp&+ zna54!pXv0(WTT79411=Y!=!)bzI|0!1D=0(bvT;7lc^q1UaRb=R@n_PAgEO2euJp{ zG^VVRAQl2$Q4z3rr6Ncos**9|#@VC-#)wN#vm)=6mn(61FWj0@?8I}=zhj{SNro>E zvRKe4`a395UdA+9AY;tGsFG-pC|Gz)K0Xfx5sbS`)w1I1nJuwmy39LgSMn27ZZj`6 z)|xoF@wX0GU<5#V(EUNgbCieE{5y+FNcyFSESsvF) zjGBdm1EJ#cC>Hq9oO9bQuZFc5UD8})?-h_$^uduwEYH~OWq+Ljy%E%C&fB_PJej+S zG&QbORn7yJE+h2F=2&05c}hOXcc%9+w$sPwa51FqVbY^TwGJ`4I$^xz_!5dKhUp>e zp^NLZY5{CbJY7uQH8n9w9{hIc#7`q18b|HzQ(P07UCBwIRa`a+8jiDZ`9a_6X=6{E zNi)SgrRMg0|5fc>-P0=S!^|8*DaklVMk=DpV^qWE=^5j*AhY1ymwfq6hKAR2_piZM zg}FON)mEd+Be{XAKo?7Ek;UPoRN+Fk%$lPB3pll^S$plu`iRsi|DAm)6W6Z(&oe`d zfTe+TbpAe%nrH(b8d_RSBF2iVuk9;}^74cUSNa`_eDjTYccxPoB0f8p*c_Y+W<=q} zVrg!GMQl;Fm*)U|hZ!wB05vX~`yzM9d%0}s8RgeR?9b~i?A>I1L82lbRRqD0qqZ{J z)udQs)G-!5b0u0`<43Qb)2a>9L3gxZ0UKegj!h+tRN7u7zhd4lYa+SV5d*fITyF8Y zv5j0%GwW4EwaoKv^ET+PZ3p(s%&kA%@9DNEMX0GMY=GE6D(Qqd%mQTzS?_n@LaSlN z_)&9t8Z2>ejBkA{DM9I_Tnm&Ms3W^Iq=q zuiX70>d_Vv^eCEw==CV~KgPuQ`?chpIUs5ZJ2|;*=6PD&bR%Up?Za%p&cf8$a&vUX z?^TqOX>yL+;8h=#Q`=IIS)XhvZTU*s(OJ}c#Dh{bN*2ljf_=X}2O|ylV7lA`SG1UOuXC~IgCRik^<9WpGCM)0< zz24njJBfI=g1Hx>-s7#^FMTAKPD9*LHyP5H(4a1GdJh!6k zzaOZH4QoE)eQ$pa8qllPfnLjXzg^d<8#pvASz^D%LBX4JZtsf0IPDXA79Rl;!S z=+xTBOGbp{0EyU#CHrobTHMH~VyKb?{!K5|3*QnOeC*t%9YtsLf(y2va8J;`zHU3z z7D-2>q?EmP&#IaoZ0`Jn9$NV6|CGzdQ`%PbF6Z-6n^7Nvzt%M6R&9`SnvFp4R7L&wfq>Q4uG=7%=v zTkwdJ0&ut7NtLqq?mPSMl#q9D|2oB~T&%iMWrD~vBbWyo4UmAgK z5bLY%qE(EBh(vRDd5ek)gE!!0 z_UIjk{SY+ip)}&g`oE8{5BIuI>(Wacd&y8Q?6MPRD|+G~=Y}N|c;u!UsW8jz0oRB3 zjLu3J`U6X_%#08%piqq739jdN#0^^=IsF)58Pygl-Nx|ReJ{J*_U}JVT0?C{>p*KF z+SiZcqK)U9AJ4Ma7!Oj{CXhD@R%AIGvl+f7;4BOlJTw~EfkH3LtcJ>lCr99y07usf z%wrIwgQQDsfuQr#Xz{b!8f7KLi)j?Q-A=; z5^1e8Knfr*nQgDco5;V)+V;7t$m3;9Z}%F&k(3n~WxVla{T&-^EilEZ&^5EXrQLy)s_j*1g~e>DWVUO=vDmd^hbQ4mY}|3w zq5}|+KjrzgJk4=r0)$?8`I`yDu%rJ8to2S*RIA6LCOUEig*_+h&YguJ>r0t| zT)37-CBkqES3QO!%s-)-0q64(c%H36HdsdVVPQ*05Q51a#>NMMZNLvHFR!hhQU?I| zwAx-!R%m32n)~yVDfw^TP87H!{-~Tf1|V&X6>V&9p=}|W^6+rkYt$7pB|MI`Epy1r zbg?~bHgVwu1C`_7)X^1r(c8E+7QDLBHeaf(rB$XcX&0*>A!FU5^;Pvp+D-dMsozsR zzh+j1VQ>&lD%<^P-cBQx(Tu?@o ztSX_M?;-p@pozzQn4Lz)oa4GPH|*eggZA_2ajmQE3(id=<>5?9RT=p+ZLxOv_J3Mj zJncc#bWRz|Q`vI%mtI}tbUCl>)--2W#~<6H9+o(D#poFZMhuhzmbgYAqabZPtu5md z2Bg$yEEb0?``&)S0K%W>5u>Az?71j3%6h?YdXr8LYM6X>IT)=VrlB<;h6wT*U@iqX zBtVV{HU9b=)nPLHKGd0+*KkXV}5oi zbF1X#n9d9RVqBb1V!-(Tjv$!>ch)xLCtS~NxAgSnfZ!C*l?ILYI7FhaBQ_p+u$?eM z{SC0pRO{*9yUg>%Oth_G2kj$#Tk8!g3Av_x0zNA+rx+msWVk2P%CrH|Qf`Uo2IdOJ zV~_u+EEJ)mu3m`W%8Z*$C_lm!jN={w3! zmACulW#CJ5kLxq$7sYcXd>wil60$!&N)CYM(5;`D+USf#9)zidGHB@Vn|HQ zetOxTgDYtSXg6T&c#56aFvsZ-3}3cnOQrXW8gEr~2w>_XAC`xPI|F0^Wr4Aaikt^% zDY?f=f#c2@7cH>JiqlXni_|^qmh_qxW6}hxm}Coc^D|i$0>)&R59Urr=eg%1hvoFu z+pvN@n{JRAsA$tEsaeHeqBqhJP!uiCjZb(gz``+jvDsiu@F5)|Na(>EFrsDB*x6S= zH`dlq2N&ZI0~#L%4#MXInBXhd)tv$zBfY`DOuxgtl2|VDa8J#;a(=M;ih!MMH}M)hO{2+(h8hF-2XOUvD0B6xs(DO)nT&8hWF^ zs7PIqiSr`B{9lXKj0aYnzae4`6IWjhANVJJD^sc;ZVSud=SV|j^@!>Xfj5-K)`3&g zmNJ#d&C9#8<~mb9q4F2>D2BT5ekoz;g*j<)joVpx|KGpb^8SNIwctztF&OR7b2nL5 zpdv?8)1!b(!ng(mUfeD*D#_Tn?WMbxVnus%6-5Kxu$X}YW%8kVkd~Y{HvlTXmDX<_ zj}dcZF;0n_`{&T8%ll}ua;n_SZsqaZ&ztu^sk?3fq6snOrf=q4il}@mqErno8YM6} zPE5?rN4FVQG?#3Id{tqt=2Nj@mSe6OQ@#XgSFM1a!MWi=SX1!R`LljP-fIgoI#Y;8 z22qwGwOE49IP~{$fX(aBnY^#W?(=#Q2yg_^12ox^0>Z(wzu>fMaH|XLsc!&D#I|^* z*pG2lnNyLbA_of10sB)$j*~_N7o;ExBM4ctefX>Z+&qWeC5{nX$5*p-Byr&L+8yub zFh>{XOGN#T)Hn*)^E)5_L71vBVH`8!*aHBe8apRGC;hGLw8dOk0e*p+Fd=0Fb4FYW zb9|rDD3c*F9sp*!arRWH@JY;j->=&|NXDaPZyzEV!I2h8mg%>%Yxk^rRU5!9>R>@V zFi(}zdn=?`vCis;afSBr|0qE$QiqzEx6l1pzZdfGVW(_a{^+yFzHR;xKtYBWp7Kd| zh6~J^1UvDx<2B8;x(^^XG~+GWH07{pc;XqIrr#kILTBP21n3F11ZX?a&ip`kB|}Dc z1_8$yFu+CuF+#hsXu>eZJwF%C0*x1)fSMa#-|8_ywqn(E)QQp?_xw zKyDzzJMI*#+b%G9ja=(8+s{@rCE@sDa>LiY5}Nz@fzAX~!md<*ejKkniv#yead2rnCeu6*-bKq&1os zG0Fg}qQU|Y@D05Q3#Fh@gArt)Z=zALeG=V>_;h@|mUeV5m(*>%*bOpYv9WHfY!5h0 z9^rzJ_ej1gfqxKhU9o-WuY3N@fkaxO`gl*IDxg6CpcF;*oSER6v$rB-H# z0nEVlIl54MDa8n}aM0-VsKK>oQd?yP=}42N_WMU4-77_mX*BMitQj61AKe&#Qk-xJ)+ zLi*-fn7rH?2lp`1DkuvlU|h)Ke#o&)wPGmAi*ZqV`V@rdY`fw>GFkn6)Umj~qPy)H z>F6j`cxeY0PK)X6a8ETeSeF6Wqdgr*A)r!(4Rjy@IJ}~{3Dk>sraN?s`Bie{_zp`c&n$twwKYmJl0Pgszvm)1eR{fe z#XGwj6Jj$hJ%2Rr4K;G(`Eob6UEdKU%}%X1i9Kc&o8^{p%%*KoO4E$6+=TU)|7`KgD=Dp8mAd2N zn`x14p+3oTGa|?Sos4$)VfM4JYyN>s_xwTG}N|KGl%{zvB2zr5G~_usDb$NwLEvDyyB1F>6tO0^oLUdFMtpZ`Pn*mJ8M zu7%#{QQ=V^))>F2(aZQ_<;R0eL~Qjo>CjGXTJ!?C`I_mkHiXTyO%^-Nl*mMNW2}*?(Vc=JYP2I-^(4R^~r&8G6DAf z+pp6o$!NyJ(4R8f(O1nhu4ujqBJw`VJ7{syq2Mo~Q`d^iQ1Sve| z;F?kD+`j#NX2620j*-jdh1}NxB%^Y4KnG;P-kxp^hBz?54cWx@(wZPN>Qz$F? zN=W=gX?<7x%LBx~aTOQ!gyZy-PCqB@wpJ6e1*#@i=pNW3nBUa-)NqCgSxJ7Bpyj^# z_nu>BAT3K|q^eH^cY5WpLEb706y^E??9a(wOU=wIpRM*~ZeFe9Q*r*Eg>MgipSo}z zgBlc>cX{Jf<4vOyzd1!NP?4wO!;l79#f+kYsIu`p5sRTh(&dBS&umcbzO`R-@_ z`7shWdmXP;gZe!vdXVN){@ZwOW5oh1BRjJI!vy`Qex^=No_p`Mw|}jcx#eV*(^Cy8 z?3{bf-)NlMZd@XVyO&;MPHARAj8>HN!1n8Z-oG=jzrIH&o@7{s(OQ_!-m@lc67q5# z$4;M8NHBgEFO?;9mb8rgjse&P!z~JRwo%eBhr)lqA-)SaMpk+W`}2NTJ^fDn?Zjm7 zlPar5G%mkTRM8?T!kFxm$b_zW(e+kTZ5s_YIVnHXk&|-KdQsv;On>iddtkm?>2jma zxu-1dS86X%j;J=uix_uUl6#?`R=cf@!FvzQMTpR{E8}g0lIzEKK`a2GebjZ~TBW;_ zmhyQ!=Z=z>hCAecWRW2~DMbOqPi4mKrGQFvw|vc8QHTxkT(;|JSxPG1>90NA~ zm{EgUyMjw)Jz0LIDAUzD#2$A900M`Y=R-G4WH`s%Dtl(aN%JtauU%tblTC;3EIAKb zjm63VDJn)b`;I{dL2s8IU2Px3i#CYiuYV6t$=n$gPEc;(|KI?EmaU~sSg;RX6zQUo z&`UA0g9ZLQF^;6l+Ho9wAnn3J9T=P$EVaO>m( zJ2mb06u8WTK0bXx2l89hfI>bm9=bYo9j-gT2#f{r9WV&JC9%BUaOJZ>4<-UJ`Kl&L zW~X?1&bhSJz^H6_O9zi2WwYhI1k4vJe9wchO*psG_=1c__kkr>5Hn}mz)AtX*PN=WjH7S7z$~bwzEo&BDs?{jODD@pp}nhjS0a{cW9Z#?E>%mb z07c0fIabXl+32re!?3ulr-jA@HOB*K$gh79TpBJPh@-z{RoGS zNoSUa8gw5B-!)p@W@n)3?1OYvV^6R#X3>==rgmm0wnUnjG`C;y3w)F0DXj1YdLBCP zEkSGblsgHM`|nu z$DSbPVS&X(DZWyb1GP?h_z9f#I7GWACE+XbWZ6%<=O$HaI_#c%u{G9^pvoo01nQ^s z&-SNUX@2ltv>cfmlsDa^PZhPD+0d&>OYJx-1KVT2l>(Z?5AbEK_TV@4KRw?= zRdh7JgNvghk11*0l33uL*|X1lK61#Dnm@oiu|*p-I#Xqyy3;H5gq-9%U$d{4*Q(E# zHw-)JF1eprQZj9W+;8i!X8iJ1^^oj2*-h;WkgJ{$d16&$_SM|IPpWO-HhUM;2JG^g zrZ6P@mZjIw8C!*3lqS*P!9iUuy@{tMu!5=C8QFP~3Sv3Or_DuWf4uCoGWkyv=ei5Z zoc@YNtDWwF;KR56u=O#F{4T5r`T~i7s(jtl<>Aj^txx@bC=!c~9qP151eUry3*2t{BI&gxTtJxzbvkPt;8`y3{_**98ZuR;1-X8EtVgI{3>o!N z9DL~D1PUa?phV{Z-b>|f_U75Vr0H^AwjwLLwi#egNY+c8kGRGQ=O3`z<0pL1EDr{= zK(+sU$O~Mj6qJH^jDLHv_t{=M;I=Mzr5;lGui0N|x7TTm&J5zD3!hsAHJC<)&zXZU zs*38x2UU$vYiwT@lqJyjzjaxEzB)R^q@i_B21LIW!tSVdS>9{PxXZ} z-iMZMkUyoWAVt6H|xvm4kT*v?eZV4-go=!+GmVZ_?Z@r>Uij@KgT{<##q%|+! z%FD~VxDRO=b19gTH*ad~B+MYp1#@6pkn1<*{Q1l$N=chITDYAY&HIpD-*z)Dnf4xBx6KcTk5?51^9o)kLpE}0{a)Z?%-B0$b?!MJZPFPJ?q6bg82hiMl_Md?r=Hxf z$tm_-4e|lZ%G5=~Z|7J;NXfY7u_G5S0R25#3hoqS>1^|Cc96HnvfKV~j_vi@mI1^n z#U`aSQ+>xMjbnQ}ggu;oTV36WZKs!AST}q5L_wWD(ATA-sdf?HV5mjNq!e2vGJziqv+BzftL0)PfS1(|PiNM_HpJXTxDqq|a)S~}~R^5ij1 z9X~xnj)i3Z(|txy_C}Ja-lgsfWC??nZDxvsoVX%Ic7HmG&_-bH?)eM`eyO75hHq`9PUN&`gS( z;|s3a_BxsRXYJ6_nWivGu)2j1fYigb1(pPG)RHSDGJ+)n<%Bd`p0Yf6&5y~Sx5sJ6 zxd&~Wess?Tc`uwx?~m(4O7iC7U0z*A76&wgp-2#gI)ouibnYe&38aquSWGjp+Awv} zYpawc;r%+98w_Q3!Wdz}7#&PCGy>3QQ`imvWW=$>Dytfs(pB#F>YF^cpWn4l?i;C0 z*fxcfg@T|IofdLTBxYa0!zF}lzq~9a`=G0XBqcbdu1@N8OiUUzJ~Vp`V8n7vQV zo~kQ)7k$TU95jj+Ywe&~iw3naU#vN}sg%XAQUIuGTY|c0yx2=1VeCQWgLqLCA=;Mh zE=SQNSt)=d$=WVZ914c$ru0f*RpLD4RISOz34J?%kTvxRyIt2+ICOzqLaj?t!sM*1 zzP@gF(b=J2t;(K&fU#Lv@8AZBT@da@LpxsU>r<#heJ{jb`%)^KYI;#IR&l0JUm8j=Q+*j&7-=kTzVtCYucvH9+uJKc zaVAf~1>CWr--Am3T`g@6_wW|AmM!U$b?Z=YlWn9 zDaQ&wxw_p>esF*#>uB+C51WaM~8W-pG`}5zsH%^$?AgdzhJ!IRQqLjL31~Jn2eU~S! zqx-0~r;4Nurx;eu@-Ldxng5aH3G&M1m#wy;>TUkB4a(m`34y9D#ExuSk1&JSxePus z88~fn5RfuFW-1SWUXlP(1PpL+tzb~abfJeqRM>L4YZ6(UScqj90Q~mteG-boDtb9x zFWI+^^{%W!?)t&7!>j*%Xk8W8i=GtOZXL3r0=DqR^Pa|++*2ANTiZKL()e=t-ZbuQWN=z(So2hUy3a09Gb=YdABMjMeX&FXiMvhB~Kr} z_H9x-6*))lmVL}thpW#^8ms+7eT{oT_=?W@3O$^g(o_9+mFkY&4qd@P77HLsQNZoiAJ@1h<7 zdk5mt-|(B_c=nrimsRj-@S&l2V0b+TYq;s7vh9%Se*7h|t9?CZ3f7KWft{dZsd)gmSF)uN7>rS%u^su#YGxt{&^ zHjl;RcO+&2=NG?SeppBGm`?M6HygJ744j=H{o^5KwfB_bV;apx%|SLPUQM9(PS(9L zi{|3RTFGNp8&f9cN^Lx1W6f`nEj2 z*6){{*PWes_1NgQEzWZ7ecv^=4}F(89V*xs=5m-EAcoayyx6Rf+#)BKAt>jd<`$+% z<27F_DEP82EVr$tW9qpTLUj%Y3@Bbr(yhhZsdm!3`p~!AXo0emzvUJMcb) zVu}eN)4hZX6NHbElarv9e6){vx3EpN!F6=u{DKJVqmZ>z8b_ElqDB{*f6s-rwktof zX9WpO)gK_@c2v|dRe!hY-w%<&ndLJpy}oWLvm|IoOOT`G$_y`>mlwe!BeXcQ!vF(w zbd*LZrEH%CXNHj}b2~aZo>V%URe{M8 zf3(5SpYMUhk@LsLrGXPBV!VK%%L0d(t(j0-~I*$ThXMMw{1(vdJ~9@kTMd6-EZmWVvN+o zMtwSXp!dSdyA+d?fBBW}0qrBUTF#dp_qO`E_sqXQD>I=2V2b7v7r*KJ;^6EOhTvRZ z);m0ZSNVH<@U-kD_%ULNzyUx2tP@i)IQ(KnoTvdl z;@bM(0f;1)==9yq4D%cu7W6w%P@G>CYsoitY>ZfSBlj1B8Du2P zg|u9o4mURszE|Kgt{DFM1^uHV?&)hJE(kZ%+o125Yn@Cjf41|JHj4O`KfYK!eXNPG z)j@sj?$0nrwZiG1I{2o={@6cm1W+`&^)xCsh3q|Q{QlmNrMrW(o!0#J#z8u;76p^% zLhj1&BY(QcHVl>NOO8Um+%pJmOFOTrB*T_+;B87t00=j;1GT~HAp7I`AZEhCLKr^? zA`6tHp|XR=osgl9g$hoP%s;o9H-bV9rP*HNYnV^*ZvK{P?+vTC!4Mka+un>L1k&u+cHDBG&1)kd zf-OmcbZss1wvekyD~dNx{lGeSSS23cLa-iJn`0iiY3tVV57oG%X8_FK-uOO1rmd%V z7J^4=A3P? zE&=>%I)a(QlHt*l=mq=v`0MrJSrj7|^EESXTrdg_Y*juB;0~}xTq=g0CBj5Wl4pzCAVU{( z5hD=AL5341HoR$U!v|stfv3B=sQU!xkJkW)LdG;{xKnvYeThqqv_smRfO1l>F?z&k z*)Cd6iMQ%XC8#pRLUEW{<|8g_Sm5KGU;@#mM@K76=5S_cz#r*FoSx*X#EiXnA0d!# zP3R7jR{1^GE1vt1`-vExU?2!EJh2GPJagux-w{u7mZW=5OKlO2pH`#ec=tl3L7pOE zK%XN|%t3L$4E)CJ&F68uErpK--(L_wOG++x#H3n&hFPIt`4R$l!dM^>2mSCDhEII^ zHi@)I>dV0ZKoi*2FZ+%b&vEvQ_+%X;QqJ^!-)L{U zA5pz);oEv$Q71aMbQzwc(8SVs!wktc6BT!vxly%Jw?$UT$ptI@B#anZh6*FnISVq_ zJ+mkB)y=T5Ik#?34tP2o3)?6A?@sZZ_G6%}efacakZ2eJY`#&VHbgrpr}~ui-pQrj zl75@DVjkX|=Ia+2_~u1*p4MhVv3sDW9R`sD;R1gYKJ;lv6bj5w#&u!Mph1Euh!Z+1Y6|X8`)8&^jHgULJ3>9y zP$^{n*@bKySp((4NK$B>`{>5-bv8^H+}-!?e8n3pFTk_RpX1nfY4 zZaTZtK9F5Z(4P|`Zsc#<5^b;(DKw!K{E%2{{ja9LH?T_2A(7pbAFW4P zZR|0Mx<5n;9-P69iugD1N@4Op--~}h6#QzI_Ch!Hs>5Z3kRf=^TyNrSLR*Ff*W9hN z;}_`681$siOejO9%^70>i@2&-6oJls>|A)-gijE&Rz7$SyAttFFZ22rMGLR|STg96 zeu7L~jTW*1z#$3}J}+cq)Tu6)ue4W1hn5xVwT)$0-%pwzA5C1sN1MnqjEWDaQ&66@~RM z3{}_8`cy$9L`~E(&o*i-8xojD3d3MQozl{94Z0T-Qx)0^0yQ5R*JrSV}yMtRYWK_8uy%0ECnlf5P^(LG49}GmFD^>MJfHPha=2iP;dsnxMTaA3Tc2j(6eWdxu zDX}P}`QE5dgjE~f7O}OL|7VilxpnQJHoL$dGgoE*dB3<)mGM2YJy0Y*l264>9_O9* zJH0}x$E&``p(%2E`h4C4Xm53GoyT{E(`WD?Qo6Hng@ZQVyl`SrmQJ|A4vllKpI()P zw~tT&fMlJ<1J;E-&dOlT3ukHjE(aDyNHmt&R#ox`lZA>(>d9AK%L3BW%)*~ z()Q?*D@W>&p6^U9-8avlHk+Y?lEJ+{u}kw_y7Z^-cD$o{FbrBZm8DCQY{J9kucZ}s zYfqbYa?jk0YCai-h1%@+5@gc0t2V{=ySv3kHxRqlR(XI!WCw8_L&>?2#F+^c=(X7P z|55kQgK$~bHJzUk&Q>(fy(Ou9Dl81S`2hu6*|#Kv5m5>XIiFG-y|4r7df@p91_0OT z3}j_xi3L%Tk#prdDBWS~G3qe=$nT8_`z*QTQ_RsqG|IZ~!c~B5&e2ABV$;>$9Q3|< zpS(L%XrWzMn63&308d+)SMHZs-ny#vqLt&e*Lz8qaOplLO8=O zGo_=4!ugu0!z2jXW~hO9wr?0ydh6DtVFRHI=ki6Mry8lWKK&)0A4NHZ5|@Xv(_Om7 z^QyE^PAMzG3@-zrU4|Y`nkt@qAg@RMAbJeCI2t`TiSlwJUGsw}mYT`WhkZmwY!;nK zZW#kX)wTKg2Got#Se!(9G*)TcLUK&}B~-a~vGy-6=!^6-RmE!yU24DYHAu1GglhJJ z1?Ib6SdZC$Q+LJDzjP*dEj%*F^N`$%n2u_nph~|FgXhK$_Jpx4b$>TzTyLqx ztP68~^LRqIq5h{|M#h_U#2P*+1uhguBo^M*NeOo^Zm)9(NetbV;DZ#SM*fGe9Ay2& zhIoSp9j4ao753p7MNabWxecvP!~4A2hX5#j;h>3QDf_s(ud$X%0iiLNF&bv;47;D0 zXOjpxe1B4fv6-rue})&`Caqj%BqO2cdy&h_^U0~TQV7fdK0E<8*H;Cd2vPJ7X}kVg^`0EI0Sfm=C@`Y`F_`JNljIoO_Yx$4T&ilY|iF4 zk$G`{G@k0!yHCG?!zO%l;D3K}Kxu%)aQao9IZg34X_WN4?{0Z(7b8EZaqY3Rw|nH4 zE={$JDNylSY5CGjO~ZNdA8W4v#BYJHNd}&|+m~GV-g~98cg9}NWz&9KC2XI91HI79E;{fEA_H~_+!@@5o!XHKoS_R{IJ;SA)Fwk`{PE?OFy zz4%J5Zk(Z{{OPM&Y~Ws(O&S!kq%X@XBh!x{U1P)LvvGreTesrEDrH+2H#}PpKNvn~ z?N^sB>0)%q3RF~iC5_I^-!EtHVGS*wW`EWE0g^q#)#z1i%cuI2n2rzhNF3;!e&qUf zZhN1VWfpEr%jS=jJcI>iYKE7YY{2diEi{@jsq;D5mtY>!v;*w&}p({!KdXrg8x$ z_@>jjdWGK3tHv(t&Y}op;y0Rqmx209H;-(sv!mR44BJ6z*7n}LnBc1|8Re0Alh>c^ z_ddVq`^ieyjf$}tN}OQ+S>wu^CU}Rk=ir;`>-bU&C!Vg;`pW`Kv-tQ#tIPX>Z@U+` zX19PLpm(epshgnwuKS1@s%R%UtZu-vm;wMx77eWn&RnW{Sg7XmS0qgHO>e8s?0)fc z%=E1@-QN77eowB+t?ckp-Rfg+&ba%(=&#=KeTbcX&5{$NPvjPly)sK%bAv}s_1yU$ zHaAyKd3oE~a_e7la-my~#_$>`UzJ?3N&T(a*1q~_(f9d-eY%Pjp5)Wr zvj`O{FCU9uVVN6;q@L3_@>i|Fmxcpugbyl?YSJa@rFK=F@9$l9`nheKXL#E|>zBoj zhJPsi)f}*OoNb21p>TESotM{&ls_L;t?uFT%(iQE`5!k7`iAu09O+wqV(uu9P=Ak( z;!P9t7dP7FMh)$j$;e);-oKZ0YeJEdpXQ#pr`ikK{BT0Azrx^@eh3}5eJ` zqT-}ChNOcUY|-t)-E$EZW7S09;AVX54p;@xt~C(OR! zu}5p^^UjeLzU5WlJ2}*aU-_UlR$s&B`0t~4D-Os~zVcy8`<6$wf5cbLzh0WPcv5Ae zowI$Iyg~4;eSwLyepQ%#LFu=Shl{H^-WTeY+H4A#H|VFcT$&0ozl~#Z`z?OH6lVBB zAdSM*O}%AE;MMCx)Lcd>oYSeCzgvIY&42+mNk3Ts%G;-zzRny#@}k3H?Wlp`#QMz> zPanXLs=!>S@=uBCw!ta)f3utSGWY1~_V>|_D)vjtziR*b==1PhmEX^r|LEI5_ha$C z+q?H07_3`+{85QVbWqL7H}OTai>|LTyzZ`Ku)(8au-nj#iYb3NK6Q7tpCOZKQ|J0! z$DfN`q`6~X+cj7Iwrk{-y+;z&$KP~Y`e4lSv2*q3PM&n_*CMTPBd+XyloRm9fC@Ji zU!O#8w3A0J^t@+4&3;5Lc_L0 zo8x>OCmyU0*g9$Wau%x|)~!%lc5Th_n;~I~EdILkeE+u-PcNK){^vCz+oi?Bnw|UF z*vw(qFn)8ZumAM6xjpRC8@*q`HXofle7EWrs2szN7%rH;^6FD=+@XyVz1CbmkY=hI znf)NKlaOYFqXA%6v^Ye-jk-e_rom zH{LgHzy6nN*$X_-`-=+~2QgAhG>`oD^9`;`3oDs+e9i>dbhal=rtX#CDI z6V9&qe)_2TvdTGJ@gJ2Zld(zEWGEWKTQQL*L8aN*Nz%GqdgtSX?`81*n~ zV52pKg!0qpRd&?W_RG!gZeFRujuiM;E8qMQ@u7r$1Yc>6I>r~r?pj#9thr9@h<_1_ zQE;JQA&D$OmXL;Z#@G`(CmCPf96~>aRo4BT;fXuL2glv4ZgvJxN85s*jis6bU(R)= zuKCUNH<<5n&u?vaBw83J-)X$WM250Z#?hw0>zeE+($c`U; zWSH+tWojyZlFwo^cdYT;vBd0+%ZwuPvQF=O>uCYF2;xU&c+yx+!3&u1^6CSLMn)6g z6pPnnNX`)ln-|}{+CIu4^t5g*QWV_w*G=_=k+nHQ=G=<@?DqCee_;CP#-Z0=oLj*q zq~}@0zG>6hd?~BrL(Z%s&5U?X%?)&$I^XU5YrAK6k8d_1s?nseMSN$t)8W-G_uhr* zlrW;`cOg}tdJcB#lWKds>Wo$Woo;4V&ZP<{LZ$5~c9ED@_>3c0qh-3iQI3mBDFV2G zC=mrE7mfM&q8Vv_%vgJUBo-@VZNjL+kO3=|v(=x1m!~=u546V+W5q&X?afeH7VJdEe+`LaK+f1|BG6KG{-_7u)V`_FUhw zLf+-WMVSiPqa>P9)7hAMV}#|g^=BCH5PD0Ro&6k}Ol+HZEov9=f3QJrCSp^H1pZSi z-aK*~m!V7}*bcWwk=*vHB0(~a%rg$pHB$ATIjK+1-g$hqOk=JCmu-gcvBrwuviOAj zpy}gsIzHC?zx13OMd!z2ua|@#lmRxz#tC#(8yO@HN_Uhbd+%?b+9|WOMKbK+{ zmJ{^FmQlwR^WMA>g?Q$tJX$-$+wz^i_{DYVxz`4|v&>Zey_dP@9-Thw80ZI_i`W+f zOlI(Wd#!P`xLWkmYbJ2(f%=|8=W%j(-Ww)k#6&yYEO989`8MnwP+;SHdjT)A$q|h zz+D=D=hEfwfAh-pnPE0(cFIUc_ut}gJ=BefKwh^ zawYnyV;{-H8=yR(eWMn26-hfJth6|e%JhDTr9rNpU%$Xn$NDe1@+pzz@Fk}wt-JmY zTW12-^V+rh*a#72h*W4NQ#)j8pbSZfOxu_ukxe@^O9Misl7!GCV}?pHG-rwuWlEx? zNoW?5rt`gd-uImMob!Hsp7(k7CjbBMci(GW>sr^kR@_R&R%`$Lwll_FdXPe6E2R#mc+2K7YJl9tF^3jI;_diwZ zrEce8=R>(uuemA05;@4f)GIBB$oRuC?VDN=yLC=eKo7Q z-lekZ4|BW#{WeYnBbqyLrxhCd35ZZcHAR0t^_h#8L_hOqXTxU6)EdZCe(T7r``e?27mggM6z&EIWO8-?%z( z8dvG{?S5y|1k?DKC0Cvgq)3sFfTJ=}?UUDedG!EQS?~8Y30xpnNc%ZWIS8}Hkf-ep zfXHpkJ_#SRcYRhFq)*EIe%(|0)%WXNB)9BbrN^n@N^!Axg5k@3(#;7I@h0j6dh8Eq zoNXImq)HUS*NnpmO8~It)8mh=^}S1@$|V`7pzzMX2*Q$Df2o(7zYUt2HEf*qklw-` zEH3|k;Be!sd>qc}rW%i}^uQYFZnZ=B zpSg!mVNxjWI-N`Ij}!iK;hxkp{Dr*Tyw}%_pV>-iPv)oET2xY=k>9Y8we0iLw>3#U zE#|W1$h<@obuJvC5E}Y@U0rYZ-I){DlGAt~*jVmb9@>XcdGO%DY*)+6xg{sVvcKXi zog}9fnjN7Xn{OqO8p0RFaX(g~F`*2<@FH`6CnkkFxP)}Gce2I1cooQ;4ZePlJMcj{ z9K!ki_^PO5Nu2@*6j*44W;rgETHJHKR>-TD@7s#3CULn}sF_cY_x#|eZEUJq7BO{r zP*zXZpmg(M+l*PK zr@jn%NRotF;r;s$3eDxTksiY5bSXWNoxw^3o|0-{H|@}aIjThD5f3q>NGgOaCRm4D z5Rb6s$OVI`aS@hA`Bn~_rWi~ebEfjoJ26J8r7KQNsj^9k|8TyZ8_x|QsE~{KOL6dyNVF?#!|%_EHQD zodZnjmC|Em&}e96;Coxm&?wn*o6g?){Ac;m2E&WKnGMX-O8LGyo7#t8 ztDuR3^}LHlj48%Fu5Tux48985AL5vlSPRHYy#^jxN zDO`$3OsbK8#25|*nc?z4OW5d$vc@hxGm4)FseR^~E^F=~JFXF9dDMUQLuJ--6=~El zt|1ked4F%jHcekh^0?+P>BcFh6+x|hd|m`j9vmW&&+(3riL4-tRASTg;&yBk}_Ye-X>i)b=k1_gROOOO}@gbqTp-neh z&bK*yBj1XzrqDpLrzAx`C8}bK&}MJ4$qK`q6>|VWhyM$zTmQ?y3{woID6#nCF8=~+ z!Kcnhk~u3tm6Jt9rQJm?s90dYfQPCro(z0TqB43e4;L`>)@e+A?X_(_)7t zbWF@WKkU@!X9}XKAFVO>AHNLP<)ud$$L@b0IgJfj$IA>XRy>m8WD%wC6q+$PtClM{ zzHIYnJMAWgC2FbK?R-_92#zUO$=$KgsMoqwXf5_MwJ(p!TRC?A!930bq{>Y7c>V5J z0?pJ^VA1Qk*(*6~p7(^YJyL?(aMQY6lA@74;0;wo&+vnnYo*Y0*nX$o7;F68(mX7GlUu*Py*wBm- zPgm}M!QedcVZnzp7d!&wU=x2lEKEt+=g>k6S64+2GJkGn^kD$6pe;f}_d!t5+B8YS zZ0Ww^n_iL)&#@LJ8&NaHcyn>eY!V`*Wj)-DAub&Ryds-}AzHp(&@0bYvdcljZ5%d* z&36_7Uer4pC+_23+VM9y!C+AC3R%w<;S6zrwW2#Kc0j zFn{W?uCAO91d2b>$GCN1GM%D`K{3h$P6$9i;P3U``W<OF1N*>S;7 z9^@EOa`kx#vLKj{nE78c%n)5dyvAp1&9hbu$|7QfZ0WK^0e&?oYai_wgl=FMn+1Bb zt5}0ZfZ^7xxIHU@)x$zanipf{iF=5~c>4_8%LD@hXXF$<)O^|HT-sW*hV;Hoz&ep( zY>`Ak%WGx2FCs_Y&w%$u6JT6G)1^~C{Xei%@DJ`fkKRak(EXJ`=0CT7?J+{hUu5)m zinFFDm^`G^;+uVQRzx$v)YIN)n-o4>onP|Dja5(f^!vQWZXjOG^vSU^Y}M=a`f62uxO|(yyH9?7X#{JPI|n1yRPG&q1%h;f8g57s*2b9 zmPIW?#ie}0NI7(Y**^zIIDlt(=R7d*6~gtaNImXb5D(g2Y2Sirf$GE}sP+N!#>y9? zuUvWm@nhGEOE+g6HT&g4g|~4RP>s<5Bi0F{pyAWP!uM&K6FJx7C-R=5u4NeT6 ztmBk|O)7_;1NH(*Cg#yzCX~0f zF5f7>bct}Pe6kU{>GkLua;6Yp2*GT50wJKvr{1T_MozMQD)_tR=u>nZ^RonC_QSbMid)p zusm=PVfuIM$g~%q1P~;iQ@%3uNY!k#jd@5i2V5e%bqb!gert9WL5&4gIKb{pc^Ny0 zx5gr+;3wu*Ry|0h1x>rY%N_`&Y?v|hQl$`^E{hrEa9Y8j$!*=b4xBj-Re+)Jd`F!= zv(oMA>U;0qapqK0$=Mjk)uX6V^G)l@C*yY!(@6~C^XG0>9QnM9X>|(Kf(R1FkH1eo z)>PxV2Uvo?rD)5pHN0;wE4SswDjH_|a%kYECJ(Nus^Tp6u>WhIuXjR1g4C>Lk5l7_ zPrTMyOj%R=keR3x2rZ<2?kM+(n$oGKfpS1ZEjx~Qdo=hr9@eopi>MfadIK;q9e?%? zY&D-mntEF8K=r<&Y<8?=Tz>EF`^3IErAE`3$#!9v@rB=Uv8Xof^Tw)%ViuQ?PhF0k zm$oJ#a%>5(rHNtsN-Emn!NI@G)8itg&s$E(w6NJ^d_CVvJak&tR1Q)G%a<>g@qdVw z5;Ot6p*$!E6p9Yb;r?&#(f3$-QIlTi|ur}qtF#F+ahz3_tGa?DDiZ*LscLAt2pKOe^ayn^nT)fGy@ts;gba2WSXkRQ z?#Cz8hE!2(>x8PemU6x6H@Q`H&DOFqi;vnG`D)LLygL|Z)gOJd;YjMvk#iQDD0}Mk zef-hQ{rL6l#S(}2I&aS(+m`Ea<=MxPFt-J5Y=d#Bn3P}Eobs{RRO@rUMQX=-OV3tM zJ|7njr(&oQm|+oDAbq{iI)csOFILB{xI1Tp_RG5tS3_^rom%{>KWoyNXKGeAmE}31 zVfcy$_7#q%en^3PY3&J{#q1WnU$L2(7q7ZHaQy~m5oB3_OK|}SWEqr3L#!+qz|p(` zLWs+NzavMf)*rf$*Nl2d;`W$^%v^^ZI|fR;ewK{Sorik$Mf8|!)&xIM1$ptSkBvIp zA?_nF3Da|@1{5Y|?>xK}n3ctVufv&VULXx#9cggN&hI@4s8y+k(IsRTQVTd5Z)(N! z;d<@|F}np;IQWuq*XPX$e$r*_>+Sv_4H=>S89{^%;;67BBkA<9%e-^YYcKpNf1KDb zXh-|k=gG@1*AHM&L20JVr1Rrcf<8d%av8`UssT71ah%x46`s=f2h?t(3#Rk1=%*lv-c$@{eX`bQ#y9N=fb zS5qB3S-vNyg0BcC2mEwc*NIdiyhz0=3$=~8nEo?ZSC-ia#Hc!Y zo{DUS#kHXoZ}_whic2vW%Bv(*RGq>R5Dw(T*uO95I;Fy7=Ew!4AiCu{hv~XLy4%CX zT{WnU&t;*3TpqpTc85$>->{^OZ|8BZn33&uUwUfo2pg9CJb^_Ca{OFzn>wiZjgv8@ z3CG3NdWBM}zAArwvQxe26jMo2)`XfgHa5~1M~=#-9w{bXd6s}K#td-QC}9~{)r9$J za{dolJRp7MS;l=_9W~0fEf`?e`TUCZh#`x=QN}1?E@*vXLSxy_ctj*x%-fBfqZa%` zd=Q~F&<_2OtqAP~Hw*S-J0Y}5mBRvyA6Qq5S%RupmwzpygbxtY2I>!q1!_O}eL&>& zFJHba9M0zP1JrXhhdu6wWdSwln^6&Zk`8R=ZTS}`&h=qm%#fit9sT9$E9&|{t_Q6D z>#0l6Ee;LV>8Rx$78WM#+xGR*Z{yiPhqxF!r-bF%!I)#?U(Y*&9e=lkqM8>#shM

    &r^Y@vF-rm|zeJ7=*-9TObII=i(k)QwiP~rMS?L=>DYa{+@`}TDt z-gDOmE%q;YF6@j;FH7AoRohYG>RPH^uR#9KSkCM_LB`0E6gg)P_>i>y)f4_RS z6h@EP-6Fb{yU=K*;a+y}7#cc)xfe75D5!jttL1$FXcWn788{p$pW=m&x21o8Hd#u_ z-ty+aYHi?TK=&&{$$>;dKkP~z79~{Zeg$58!F@$48`BL7k%uRq`~ATEK5FxW5tJAsfrCZ zfu0FU2oi7VtK1m;B=f~}>;pXxM1u;6m%p1H@N-tr?sayut)BroKlY*S8TO*&6u^e0}tB?zYs4i z;0(x^6jBv)IubggytL4v9-Erdc~KtQ5_Aam<9{vM!^5q|eM(p^<0}+Y?_hq;x%9Hl z&BHhu9NgT4GtO4uCblS?JCakxjP*Xi8Jl01g>(e7x^)X=;0 zBG#6#YNciu)CbYQ$4zT!MBCcgfBO0r-6R0Lgr4lWtG$DL$M^5?(7f*aTtE7avH!*+ zFsk3u9Pt0Dgt-+CXmnPC<4xrOkwj z>b;7x@{TvGjhzB%htYu1pOstXry^k{&oy<|Xh%hi00)-TOx0Ce4dFBSuo(dw0>o-- zZ;RIB{YL#NHd~2Ac0xaA>KB9-{5wqJN(F&MQwVlzfXJhhr#$v+wC`ugxreH1bt6Oo zV<67!1HflRU6#91T3R_>iMacPItyr}J~ilCngTll*;#nuiPHb;O)XuP!;AV5rD(_4`JZPeA<{E9#oCKWei-}!vNwMR2Bln&H>Ndr^j&z(e zQsP~J*@kc?HQITl=tife28SDutG~6RNv`s-ic&FV4;MRg6H?TzhNE~_FenA`|C^u@ zGACb6GC~|jqXQ3m;4^95jdK%0dycY1C6}c6wJ$80L+VCT|G%*$dW#S7I8P!q-Jo+2U}&+`pQcpklY@%@1VJ}2H=6-6N( zxxjm&a@l5tkzDeZ=pv7Rl;u><_JNtJESkk)nwqToex7K8W>LK#)MZxz?fkA`XWMWU!~Z>C1+74~p# zCp`$-@&b!&QGSfk9h{g+ODeU>h_X*!^tpRWy1g>Tl`}$htft0^TmXv#nCWqrq|N>&ghUr> zr(3&PuL^1Q%{EUR78ZEVW{cfcL1Gl%-B!A%O}RF|T~+<>O!rS}Ik5Zs-YDUd5lI+O z2AN3Uu8=1Rjf7I_taeF*>$|sv7<&>X@FGt~^X4RLAicY5;0?kZ28D(scBZw}dlHSH z+ww~^LE!F26oeczR3T@LpG9bE*^iKfONH9*Rvj7}RazV%k?(t+(Zy1gdsTI}IX&(3plNOjIM zbGCA)mhZiOEb49xV{>xS1+|a1cD6vfjiwBfw_{Id?L;i{MpEI&x%p){kAxv(RL}%S znut!zwfNGM6AwImp98F-?`In)11aMoZW6 zc~#X8$%Y1j*oe>pBP6o044`HY*0<~H8~9vHfCWcHM(pwK0>2CMDmR5PtNssE@ppkX zBY6eyXj`2wLC^v;lhLX1#Dapb$xH$X3$1q!Zq9xxTaT?s7M7QHP6|3!U_LTjihMgP zSRt(~fiGxT0H*}VzjQ0$XjE|7%DM&yM-ld+*lWrz$4K_=d*{1ByNm4Xk81~$&QM?+ z6R7orx|i6$o}v_Js&crF4h9Tx4OBDIy{+ACqPGkfW@m{2&%b{2Mk-NtCoyapX$kru zMfUUiFcJ#imW$P!NCP^afRYOlS5x;`G~JmAcpbL?+? zNv+3fOi3@js`hnorr`yi378KpG^a>;NxPz0lG7K*=A8l_%I5AYsq$D}T9oc=x$yp6 z;#lG!HyWo1$LLiJTD7)!`Bn8lJN9Txkl69tfydL@Buq@sRo>Y$u|)A(PqForOw{aw zWztXkC}y^7xwl1J1@yjG1F7I_nUC+X1jtr$_1*Qv} z>-Xu)WA8{F{HyVO$HnwnzGYWXPb&4XVS76}+#w+5!fqO>|Bt6L0q1gE+kW#rr&*Ln z64E4zCX|Yb6e$geNEu5RQb`jHk}O3fSrrK>Drq7jqCrXtNhJxHr|);S_V>QWevf1C zwW9z3dG7lf&hxy^tIxB;Wg2%4eUjol)66azUO$;;#`yK-O`GEbU4CWL@ zJK3qZF9IJ|dXVt&{{vC=8<`R%6;Qzb1(1{imlm`>?2)XY0t$S$o`erHzI@__J0GH{ zZf$N$H%a_oMV+kCPISSH0`VFX*K0g~m{C%4(EH8SM}<3ZwN6{_c-+CyFP|Vf(IMvE z(Gq`u{Y@8~t333Y_o(Ejr2b}fWc|b9noV(Q_zXH>N3M9jDtTO7c|vE4Qqa;J^LI)7 zxT01Y-OfrmaEGd7eEyBlq}l7B(u(Zw6|S+F+KajiP4%Zw!5M~X1E%QR*mAeYk0{WL#46z9-*RwzehCp<3Hj9v5`$j`#Opzbx#mhJjl@v3+Ki z16#20{RgW)oxaoz-Af`jkn8W0LfQ_$F);A_IQM?x%jMnmLJj^#`Q$8dz7tHY(C1w>xcu27Ht(i!!4-Z@3iaKs5HQAHhxCiC5KRFc0(w6KIZb}g_3J&4Kii*N zb-d-s5QcWmlyTUCT7!AC{AYZ@WUkc7MV$iDYZALGHk2C#9|x^THA*<3ci7@y`)$s3 z+Ch@LVCX*Dw%u!N41hs2uda8qM#1Kn_7tfhRY*92Ei*Hdcb&w5Ex{KJ5e7I>xkq1p zH`w&E@1n%@6HYGLfrOi$reC+0`T5P8L*CB3vV3A{LReyA%fz7{zc)wgl}Y!&xI7?T zJ8aK$b2J=WM9g;hU6dQ7dvKk|$jGg3)g5JOKOn{O(drof>hPPbZY9;rUDvBT8+LFW zBruf!vj?XYM|H^MkIlGTFxi__RUHEERg6CK%-y*M} z7Yz2<%m6mvEl_tI^YJOPPq4J=j*{fx+z~~VQO&*x2QkWcY&~$Wu*h z`%Zo|YDQ3bklxz~>f1NGeChNf?!9c`l#nQa>wNmO5ewF(mN1xi6@tiT`W2N>dfKD#h&{=Uwzwi_Se5n+Hx^a6jB33iks~3SZqAZ`$#R64kcZf38D9VuDT?l>j5>!t-gopO}VJk@WT0 z<>>(XUQ%KX`CW0p+E+rkw6qjod=2MCcIg@HWMG((VNP^lTU=ewF;B9($2q<`7#6e3 z$Z3upKh6-0`>7GPG;l7Os28jzv}&9t+Tpg^?QJQDYkgjQZ%*rRj?t2u8Yh12TUl9X zY^)!kSyI!kqO6(c2f80Vq&#`Vh!JXgYL79I!2i|B?Ql1BJFOFj&b1WhCSg9u-!;jz zdPI)pG^`g;%@R*7ZEdf_#UXxuEKCb9zC&5qt4|;5_oSVk)t5u_6hB(rTQA1I8GyOO z{d)Ck0fQ|8J!TsffL0Kc}8#2c5oEW`-{I~B?qfvWR_QAe@^u^7uJ9@Q77r3`Q07>tU)$rMmKJ#@pK4$5!rksmgIW zk926`w`qTvf$O+#rrGT;1^x2;(Do4>=b3Kri%C3-ceJJ3L=n!J?9z|6MQZq$khX>p zy~$5Azs5^_wR^3Jy3bCnaJ#K7&H#0QlyhhHy6X14`!JszyW04mZfkY>mju$Y4rQ`X<^C(mX<7F+Nk>(0appI%-|{6pB6$F%m>? zbcsMg^t@^=RR?ydWit9_#WtGgpu)AnHvz>sr3xeOzWl*nPc|_2jM*)tHlOU>ggt)!T0*#HP}(=&X{#+@Wci-~DyH+|jM*<1la!i~^(IsSK7fGwfPl=>6xh9z;mHU=lp2ewqDmR4ixRmFp(r>)=KR-S~z zACU1D_w~6>aN}%VuhV z47XHQ7g4_OV5=;C#`Gv78aN0wV@$9~x)#F&+LZ3d>DyX7+6v-7{*u16-77uxMp@3r zQfW6SAbUb-Yk5Jd5TXDFH)ynFwdvoDo#=j-@5C8|#Im^FcDk-B%u2^iGlJ~ynGh;o zef-$@;p^Aacp+m?p&dSDB9ZXX7Q}k!FsaAo&au)T0u=XZ=Xs|t)>}BGCzy}(y^+^n z@GK_yWr`KEGAC7hPfVvin38GoZP?IfXJQPm_YNuW25vM^9WZQaVCax+*Nb}#N<$+f zeMYGg+rqnA%OWSh0M$DI$&fWjJd+WVY9kDvkO4w7UKqbub>H2yV z!|VPuMNmjI8wf3N`x)+2l{vctw@^&GSCk#we(2pO0wXV$v9EZ$;w}uyhodjLeS1cR zMRmcO8`|cBs*qsGb{lrkPGVO;mLJ$wZkfi>;pO}#HZ%6<{8ScG>vP#lf|jDrzhdCV z1p&I@fWD@U=l{8aP6(0G>2z~7spByPjRT&}fvqv`vL$hS*ZMTu;BJNU^V5m@QTaF9 zL3`MKdN87Zps!6 z!`8^3(h2+GdZYd;=c=K3ptJ-1LwnKcec!5MZCB9CQpxe5a1;Ce`**>}_$&2|s8E3ui3UIv zQ=jX$?uj#QF|2dBY9NE!x+u9P*A{iV#;!oNd>P@)=geY)&N z)JX;TW__(jHwrf(#Z$WB!w^;P+EuSzF}v`9+^s1f{T~AFnHp##mBao zUq{Hpp-(MwAfVf|=8sLD-~XkO)o||8F<;-8U+T#1=8eTG@|KrRoVn7^V|HxIHp5Ae z9y05<#5%p;y~m!QCkywjj6PtY+~2mRfXC|VhwhM=ek(3c%tH=8iqK5A`S%GP04W7n zec|RQY>tyU9z>Gj5M^5)`;l`hWB#_+BVKRS4!j^92Lm|t_=zjR*IzH*p^86_+Tf#j zCMLgcWIrT@@R1MYkId8ugj2m0%wb27wEQG5-eTeZ#$& znaN*&W@PjN4^J+x?AOQJ<5ZrG#;OMuv+%G*3clINX;4x@7V16@m-Vdn z#^c~eO{1M-uEWytQ&&f=-)O(-RCKf*XxiZ?ox)aj_e)y{NlcA5R5do%VX0oy2J1at zLH^%tHFMC8J3arR;rOg&MfM3)p1izDJn;#4wI9YD>(#gNm&a~z`e`O0Y@A1c%xv2> zdY@;zs|Lo6VLd9E#+;mz#e1uGP~E3A>-=UUk_ibeUxy6FudDRchmgv~$hA-)1G;-3 zPOjyCp(K6JVJn*sZ*i!+!f)vn-_S|Zr@a_kuDU+>@+5yb7;+$I3fU(KX^R2t6Wm+m zh7XrI&RW}1)|Ok}7hGPt?3M3UXQ`{_x7_v+gPrM>fASobS`364-MGp1Ty*vd`-Tqe z?O1vB(3QY$akq0`e|e_V3(wHNVE0E~zYacl(N8DL$k_O6=J~9I-6hHc2NpZ-bc*rl z7j76?SQ+2l%Q(%#Vd=7@&!0Cy)3E$cR#MxuwAy9&p1YO1@_8bDOT*Bxj`r69ZCb?1 zm~q|bW8xe{yrh!b%SWh(1T0j3L~3SDn{x4gExFfx2qyXoVJVtsMyp%u(PG-R`k=Qr zV&<5^&B%p??+GH*94XdQOGf2he>pqs+ENM%~q_FP!;s+4zAkUc3;Kff()C%=l0cmp$gwer_rIj8xvgYreZF zRYOPGV|DY{_QDa%O!;u0O>1&o)6PeAc@`RBjgDa0SSKk2gvQ0K3NBD&Ba`rYq{0yA zlYUT5Dr@H8BknpaJw2|+CNfS-v=hlNDM^}pd{iRFW2)f3dVYU&ktAb3bEwm$^OviC%Uz_!&J*SF$Kh&5)i6fWn>?D!%4P&O248@2wx-r-1saS|1DYJb6f|-^$ZkiFeL+V4tA>*#EUMVt)Ppire zn(OChz`9-cU8&eirV0JW=*}rjz48%e;a{nAQf2zyWPs-@Q}%kcx(hxv*2_LjIE9vt zD7$t0RzD3jSb`NAhE=%WWCiQ7AR)7`urP3Duua>G7sa*5Ux9%$iu_&e{ec|``QLW$ zUs}6puKJ;8fk8oAoVKCL-n&t9@`k=ec2a7ti)d4lq5?Sep|AJc^Vu4il9DnW;G5A< zvzS@Oimfv&V&$VEc2u->yn9#mrqQM8eqhxW!}h(di0?R&@E&)c0Hx^~qL z8wB!l_eSv?=dsDh9jXwk?bXIUwC>>;4Nskm)5~ zdY+o-^_hP|>l_)m>`aHfW2_^nU^uEkcWpEZ;C##Xe@=haP*~l2O?rK zi<(>GY4GoYitxzD-ri3@`lR?2P|SalELT^$aUB?wW{5;Rq{6z+4XN;7U+G0kLos0W zc%;ixZqOi{k{>Zpz}zPF*W*T`Kb3p{kS3-zNy`|^rI`VFat8p6EUm0SE>kyC@bC%9 zlH@fipYC1uhd1I82X+GjLRL=S+$?%{o+sUh!H<9cc;9@wb=$UaIMJ-!!TYrD<)3a& zPDfNT|Kg~1YaV3I!5|-;T+V5X$&;s~!vq5;fC~Yw#TR8Lbhx0Ms|GA(soMF%Vye_} zZ?JhJxVKv2ztx-ydRo7*oVMGfC={8@MQjjm$KE{Bs4U88oU2HvjNT(jXvp0WI(}FXa{pXGG`)~c825Mo(y=!4>zKJM ztsa_^eJ>z2Bg0HNXSYhfZfwXDC=sI`3<^6`uP_?0E|>JqMjp04kBW>vryG8*jB1w= z5aOPi6fUwx54<%9UU;$42r&Zz6UCJ9_U(P!@#fS+CY;x$y2s**njq4UL9WCqDSSKa zo3ZF5StTuhH3^%|zhEG2#FA+CozT`F&%YqkA0U$aO^=sX2M+{bLeI_etk*5;pz&*j<9%t@DP1w-!j_bW&JYCunAb`L z)7si|tp%Cg=iihlub@Bbg7K$z$v$L^)vrLDD6y;Z ze|<`%M8CjH&aYp;^sSEs^mpC^PX(Dnqkv%tm``qRF6rxcQAqwJ2OjQJVsCUiY-pB3 zK-ufW(Oad;?&7L=D(Ec`b4ZpptR#Cz=qNOTmX0^qIK1W0^|xalOYH(b^!FbWn3(5r zAAI_bHd}&TO*2u*Jvr5Q8p@e)jD zVL1Hwo2}yl($l63`v%EA!qLapw)Jt%k)^WPT9{Wadj*RBth>rVlbLl?y}M&KnGEjH zd6x6j;p-!&Y8n<9Ic$tH^vil?@MGdUt&EFA|0UbC3_bCbumbFzF~O1Y7Rl1kzO<; zkf-1RJi;)53K|XSfj-{op5*FEljHkaU-KIr%khfJEor(G?@j37sc@-DQK6yxAqnG* z{VOaP)lwhw)C~nCDsKCA1ABEbA77ppEf?&>fr?3D-Vp29xubrc?-Xz(Vw;`fp`FZA zPR`X-%qpj&<4(_#hE2=UE_$DwSjG~!`$L#Mxj%%{z*LELSUZSbulZz+yw=C3+}I+; zG+9<@=wINL(%?i{?DC0YJKpAxhb1S$x;z_XU9$2jU>Ih_I^N-o5HUTe9-?OvlPKN|~aBe&DowsQn_+N}ME3Uu40f5{<&I?6dY^=Vz zck-U^k00+FpyFZ4z?0HRN@o_Pzj)vHKD&c*zo(@&mjai1tlcXqdGpQ?^X=R99`rl3 zcx=!MTo1HiGp?7VD%>`Z8|hZN>*g1G?V4`yKYXyNYKmSn*(`PzA01Q?XAjAi%*urw zwflyxu?ay@ZQsDW^oZNV-bM4&SkXf`L_M@1dW}f9e<|!BN)K`x@I0NU&x>U}y!N@y zQ{TP#Xw~OO3&@7TxM}}$vEsVJ(Ix5H9V#+!NWMllXDP3v9snK@2)-v1sP<{s)tj;R zo|S(H0jthCs+DxBCE3T}b!!<>DSJS#&ovE7R^w641!iO)=;xF2ZjP9UYxN*EJ{wX4uc?{%v-!USd1rVfK=zjqg?2)T}=G*gwm(ryCwGix376&UK=O zEUhcFze}&4XX1PQ<2o>6Bmo*}cRT&BIC7n~%+E>o>Cqh=ZEzpYmwzV>^hu+(gh}<#)u#~zh9``_d#r6rz)y3$(rwg1kX%hRUDFI5xn-HNG92Atq z;e`-~_OUytfYGz)kGSFfmlI^nY#5G0iaV)vu!?~sr?xqD=>!=bX6@p|(IAs0JrkMr zFSZj#`p8tki6HaHF8l^Y8$Bir?L{jso`9X*$?xCcK#%1+_V17mze2ticGF}KYGF_t zjUHRqu0PXZqn=)0peTlLDgk7{E6dBvSN<7TF1HW_x%Dg-k5+y)PK-~{SjJG&zI%68 zVxX?=-f@15fdK>t9vT{o)j(@T?_JS_f9fkY%buWNNbtO2HJnT+@Nji?-939|yC+G% zs%#Q-e8NYASC4gwtnoP(izaLG>{833lF*JeKL42*(BJ7SCL9$0n>YRyBy;V7U?>8B zm&f?f^Op;f>~SGc^mQ4GSB;U$WPlq4Vcl`c=ufBLM88hDNnDgV=PmA#+fq--@-dJ z&2K{feHT9-HwF>ZN<4229LU`v~EBSzBA%uXk;(kIp|a z*p2ysUG=}|mA&AsT?)|9!{ddW_)@bQo6>XVN}o1eyCL0q4)g}JZYqY(;OVCE&koFK zz~&zX;>ycl_8^&}X2j^7Qwn3pw#|=s>*3*()h^_-t6KM2;nDt0qUG@ z&Z*_bOjT1`3IMV-4kuJ${?9zEqoZT0PXj;>r8aQA8h_cA%=V({Cp5oI$_eb>bqNv$-yPKsk z|R{oO_#h=rPmXBZ|adAh|GSEyw>sC2)5b9;$)2i#ijNsP!Bwi!_m<1J4P| z?5R_q;0VI`<)g6ATX2|MY5}m-&c5E;>$lX=WF(IpF<+-Q$?N-Q!}1$@!1|)|Y2jyYsMxqTP4Yh>k*4R0RRobZCPt>C8|^0HmU(6#usEGI z11n7s&=d4d%~tSw!&En`EWmC8IvnozuA(4G4@({@_|v~Z@P|rEix|5>DV4vO8^CvG z2lSUX(*%3XBjJi;f`hnyoLHI!9z4>46X_)w9t>l;>&Cd9J9k1g(EbTaIK*a~HqGzy z`0%V1oqn$acmeRB$}?}b&oYZca3&zY2=N;s#ZzBxHSdu&5k41b7W@S<3a=Ojrs9=1d^Tdhw&p8}6?X~4J z%18Sbicd|V#}8^B$om&@`_`?o9Y=Bd2~R6G%(8CNzhBH_L7G6(e##*bPbengaeH7M zJ(EeQGF1ki`2#Vm;9=9wJP(f~S4C)fIu?}m>A!y6usIIFgUkZbFZ}HT!UL$V*jZ3e z5g-Fd=KC{=riS+*wmWcOpjROF0Xk;uoS4<8)7ju)to~>T6I>gV@h9stis${jMoT#m z5441X@YxrxUte#8wA|RM>;f5gn8U7h-^GA&WsCTNg<#oN}P>D`CLn;JO~T( zDlFW1@ZiBOb_<&n5x3#7^sM+~X*{Q+pfBMbf9RZ)4<+`C?RG+VAom2~HSc%zKZ_^Q zEBUwNACvr}MMigAB4#l?%X8VWV|i=l9lMp;PV0_5zO;U~oIg^jvILipD$ zXMs9H_V#YCQJ=njdq!eV&_DlBUMfzEm7J5WxizFW0E+Pj zXM=)5jU&;|3x6)kX0Cq6#HSAW_mfay;i3s2!<|e0EUyydWC~GZVWIV4!J!5%*x*O~ zDBA_l2lb5s09J+(%*L#XNFfO=UM%1?!vWFS3kQ99ug336v?F1v}tT_=VkAX z)Jn{rn_HuJ-ynDE^gcy)NP`tEEN*`I`TB>`HUA#T#oZ24*p2twXzSko`kQ@eX7oXH z^H$S_pItWoPjNtqUy&7a63Cp-fBaZ{NYY0uabDq?k|7}jSD2k#`XqLxp?e?r>6znh?M~sZ?3%ZP65UcwMHQ8xtElrt_<) zYfEH*?gKe>aHuB4O6Ph;Q`43LX(p}@&9;1L`E85sR+umO+j?s5sTn`d9XrVQaZJV2 zgQ<&GexF7`$gm9~MdM#AOjt?L%gnRcpYK)Af2^?hW#_0xH7(@pudSvTlrQ^FP`G8x zjJNmh-WA>m9Ib-3);i%!gM|Tap9yO2^VBhKlMhw`Eeb}%OnJ(T8RKV~wj1`^88@_P z%Vzt#p!O7N)2O<>a^_iHfR=`-3S?A_6X0otQTA^{=AxRy(+DRVvDN@d%F?&R14pbW z>ijLKHMi|KaseWWV9D9jx&!lXEB(zgg1}bPTNF`WMr|K=2r@IM9bq8 zcxab8T~N?G6g-@FdJ_~eRHZb6?;CilEyTBKeDKCPyKR8UB z%DDmtZ@}h#amIBh~D8IBtD|GG`AO7h{ z4Iy@AZP^l@|LvYfn|e}POGQc3Iod?y$mh5Bu!uu!USKY*d8H>tBO9XE=$f}Qx<1fP zq7e~Y1KTs2+8F&39zKubXl3D+Us#BL0$dK`1J;%5|q zlyX$)T4_YQ-$^ao+k!HVQJTp}9j8de&VzurQhqRe8ya&2gOi@sQ2tUahsV4CEE1q1 zRT?#jrdScdR*x3b7uINyVu5{+>yYmHqmS9 zOFde*eA-pqo6GR!Yh?~xZI^3?*CV2$KpF9Yqz?(qu3`1D)UsFnPilySjiwVN_J+!T zYikZ^n=0rTTxb33_?=>l51Wfxe|u|e7b)ufcW{Xz;@I>M2a-G7$zR`=`UU!x^R z&lm;&G&5EIZ8i4_T6_`o0;!Yhi^O4ap>Dy&x63$6G_lPK5k(S9p5n>HjGtsU3=WF+ zV1Wn54$9SQa-9MTWnw}&+mlP@*?Dx+_JYr;d~Y39Y1*3ZfoBYat=Y?0lr8lbSw1EG z{hS>wJ=XW*r%&!*t9O7CAOdS{zJG2Y(6o>^UB7;m{e8lTl`8Y{vXYjjx`txuwu$=# z4-6gk-h)D>uD~ONSwWy61i5aMqmdi!`S~C&+AVQc_#bVRpjkB3?>Ls*1;u_Hz{)2xH?S zV#lO3vz5kgv4f?)g~7!>ygdqYs3PjDpnlh5o@NDSK-mNC$MB} zaCFfc?mhabm#2_v4+>W^BwkLrmlxZE0!I&ohw;gG;)$S3*dvy;vULWsLowv~{B9saqE5ir>w?ukCelZntel_#s4iK2!EregpkEV84FE^%evViyriyi_V1<+<9o{GSwl7E8l<0xN&_uX``jip%!h zn5g9yvL}24^%m3(M`2j_eF!kri^2UakYnLU}Uob@vJu zuefC(N0B~0ck=w99fdVChj~-<#4-=wxmA;FZT=*Gz#~tR;0@EHOtJ0+x8ZZeOh<@y z0czbC$-Gje!sE{ zS%i;?!>W+uiLdz2m9#ibgy3vg6+%FdmRyf8f<(o&8rcV?5oN*ZZgNeZ%#>Hi56+(E z!+H68=CDX+=$?N7nTCjvjB-L(wdlNYr(4~I9P(%g@F--J4!)0{x3}*iWO(Qo%r|bF zr{4FeYB+LOLdb8$wkk#Ybs=;nK+nPyG{ZI$C#fK>AtVB@^!-yLne4sFzF-D{(4wlUn_8vF-Kb zGaF^ZpfuzkK@c93uBsvCY?>KeKrsshwjhh5EwO4dXH)uDuYNk3&fm6#xTP@J%q%6Y zq4@YRNI6Aq0Y? zuQmGU&&!9-;qy@LNt;)3ZXvSSpSW=HLK=qs7M3FSQ{K>)@A4sGZmoIw1 zz;@fV%0J)rfo`aQ&Ny2l9=dsR)Uz!JicyFl0Dr!yQKYDk_p}k4^qC{96M$!*8 z*z?}6VpD8zQ44yZ%yML~TF@-9t2<}6?wSclzU_&g@|yZoq%DXp^x}-r1qorf^S!Wc zioWNUjz{CqJe}wRXg}Gu#QT685Y;jm5vXj!ECu;=%3YEQYOm+ZE(Y|kXeQ@SeG7Ev z`}d0`3hA{p(3k$RfD_Sx${nvjif~}dK;SC&27rOdO0ZQ}cw7cXk2C2&xdyzjndS=` zkA4BoTOv7+7-U+%9k7k|Y7jIfeFvO>7QWR?^fE{sml?rO|ed zik7N(*zn<(VArWwP}1-%ijY`R&EnHeksh*Kq|4eLaZOXZ}u3N^YSGj)` zYr~|Z&Wvc~_oK#S5o|p@2Tv!*wq+HBFnswgiuABK38^Q<{mqNrLenN7MotkhDWnl* z_tI{E`ZHz6Hlnu{P11|Rj}vruI@+EO*c(*NyGs4IX*$Q1|kBXq-73IzlwNxk{^ zg248zF-kkCTvm~CcBBzCF#%);$;<3)&ns3$qTc~Y&7GepiP~?7f(goiRGV1L9D+4G zbd`pFA>g1XPwQ>Bv9}uiYO>O3tsXlM1(cTh?U6^N5Hxdp4ihw2tdy0kA?d}MTRKdu zA>XPSbbaiyTV?i$P1@j6ftoqYJ~1B!XZN@FV7dqV_BS%!Mben!h|iw}w{zAGmKD)Lt0pt4yfOB7?q zCx2}cb;Ky&U47X0+2VKWjo8UWnL)`8MLD^=RxdM8rhc=BhctjmsbsY2gaK+`L1@)f z!Iz-c8Pk)AoT>TSAT;{|5f=!LGf1Bp)582oiNmeX&;`cRdT1X&gU^dOlwP8$5IKEv z?nLMqZbRCr1OQjzyG)`jaq6D_$1nHHqOoia6SkGKqT-HQZ$IMXo7yAE@%yP;DQFP# zlRQE|vs+m@n!`41;9!$6-Aj-2izpxBS#pTA^Vt^`!l|6St4vQmvWOK+JUQzCx5~xa zshoKOo$h_a;vH~1wHCb^ZAijxyL%t)%U$V>0&C=Kyn8nIW8A>1}}u)pq@!D^*7) z&X-5nF}4ECID`%8iWNbzZ%-AqQVc&stCLll_wL;WC>=&&G0aVBt%(vSc6$$*5z8a0 z7h0@A4zThnrLL%CnPCtS7f^E8?{PQ2b3Z`cfeeulpWHkrBn#4*L?dF8aM0~r@W5?t ztH<{3iUcg-?hgDYPK?L}2sMOHqg(FBj~E|_1>!_TcGFFoD#kakGeNL;^Y3zr`w`pS zSvBmUE7L8MnS2lzgs05YRmI9EAy|5qmzU<%bI2ECtu=dp{rs8Q@PlPkRZ_^4cz(z# zA`&M>tl0nPLi4dTv%61fhJFKdhw{5$``#MLC@tbXK|_B=Mv+S;J2Vl+Eg=G<_Vn-I zh6bCfg6dRUTCqNHs;X+Ek98Q^*`Wc&i=n0^e5D^-QmHE^xtWddc>NT!hQhzvF~Q`F zX9BbY`E~JxV80uqA2-bHWR8$R!1sVBs6quJ0hPyI7n9>$vPu#sF-&xqh^)F?K0N9K z^+DP(mNPqZ(g{;wjf-XuJZT52u#4H7!pyjHx#CXFovlnffFkGGuKbK=bCpXkA5D$qOO)a$hTRdeU?8!oFnt2dE}SD zp~c>snrWsC$cqO=AMfJSi$6xx+$v%xrb_SneRJ!aH07gjs+Cn&)jv}_TySemUD`7L z0FR1@fR|CKtuq^bg&rUN?QUvtv#sx*+J-i_^0vBLp7n`-`~C#Pm8bPuKhEYqOQU)@ zywCn_1(_dbjF1duV1jj9;0p_@_G5kybsUW|fE&2^W-sSTEv zi)`gMbii^XT?BAk0`jPtvh37sbUcj7^bzN6#2_L!X&!QjBSbktgmJ+G0F|bI@`!z+ z+0#%|-pR5Lk@zTd-tJE?twt^7z$p}n!4(Lev50Dx8EtgH=W!@L)hRwBc+VHDh7)UI zuAICX{-dG-0_W%&`SG!1|F=ARLT=fm;FC8;?|IYMlJ4Hh*ue>zg_H$Y{6JTVhNqJY zCV96nPuS+Z{&vuw23OJtDoo|{w2C@5>(CtFV_#Y5Ev>Afp$DHx?hNk~p?J?XYHG^* znmuBqWejyBdcVi;1Eb3qYHFE88WvO0(8DfjU&PjT$~+*E`4E+qXT>v<8hma&8Zg)f z%!^A9<>o$N|3E#NA4AsB4f>Ah-(Nv_F%zj6OoSp z3MM3OqDV04H6}}YO)uu?0WyY1+ z+QGq6fmV}#CO*~j`)Dhs7EvcLPr;=WA5PiYk5;Yh7wK zH29*BB8R|*aD8V4Eqj0bh>PL7Z;2-R8@`-%Qjru+{)A=B#2H}`Lx|}m0+;E;2Io`l zC*8gADm!}~og*&_2#OfPLXo{0RXw`wS1vYOGqDy?S@fDODTvTohkTuKvb-9=`FxBI zwxv~5dFgLQw9?0Z$#3~}uR>V>*ahS!jI2%vrHaM2uwpMP0^xQ}E`oc>BvHfzr}4UT z4DFHT*#`IQZ~U&^mRO^9vSWGgo@?*eGH8Sj9%UIaKGE$n&L(sYjg6Pv?KadPX#T~? zy)=Dt7&fQ=p#vm!!No1WA*&{F;V5j@!!xe*^_tI!DPo6P8*Bkyj4mKCI62}}QcL-E z^EuB!E8)^3JCsW*DB>-eGv- zKUZeqwk&oRK>wrT=@H4jSMD~qlk)ewnem8rW4 zoxiT`e4i{J`)7;tuum?fBgLl%?=Vjy(M6*`nfi~{PC{=ohX($ z2vC=QHEY#T{nu+D);_HW|R(ew=|wH+DDH(Z`tPaX9-?-dR^0vOuZWt5)z(mx#+{v z{_--NX0Dj9!b~5DooqdlPXXPsDvbO7g%HRHn$h(?$32?IPy-T)2K4Z{JpCJEPN0^M z<>EW>1SjrK=bDJQM{%~CoekgD2499IBolJ5=a3CE9uFA&RHP^ISe%?z5J7J0a0OUo z10=S@;_(E(#X^TuP_V_pLBH0Dfdzk^N<=OMbLJcGV@?B}<;mA)1uYMK_Re3B(T{<2 zARV{>ga)&{&qP?d-!g zFDO*PUGsBx+8#E{Pn|JCEO8!_wd8d>HOq#07|3Nq&9mTH%i8+7V-lw)1sPzB z)8o4}6n!ziO`=t0jN;aCg@ANX?(>WnRpyq}=mab}x$sZb(3myyIbTi9E-NS~z^j_S zJiV(`J9Gsom=0AHUW0c&ykK@pZ&t~n0mB5I#!@=v6OL?x`*3IblC@PRLSVPN(a8Ev zX2pS6Ck8%iM+Oe``XI;{aCko|R?vW@1e=H<9x)XZ{i)D6vITt)Je>U0c15U>S%K<# zT>;H2O`oobySis@lr74Zv!sqYuvkGaX zV;VAkJQ-*~?~Uh>d5iuxvPwgOmw?%cqMV!o#G0JP>pLlJx3O7NX5ash3oQWi6sPNF z6up@_sQAHghsFm_??~&sZc=zq+O&1^|J6I=4=uZd-=s3b$s9*GUi$^&!|mAd5Z7}E z0}2{3h_6A%G-b+^N-v)SQIqB$jyMYfM38JK-|jJ~>`C{+Rc4zWWp{bPyfTOZLPdpj zcgM~Qe|p87L@zQN`|a+1%;bY|si3T0(#o*y*K9mh*0_G%5LIO$q^D90=+J+Z^_Oms zruFN_TsUg~Gm2?4Wtz9hPTVvo7r-bbwP7TW6A1%lV7m3n=#Xu@kw|dNTuy76YLm#A`3C z7<`nMnx>RHGs=D0DbliUbvixtjL+44I*aDbY%dUmyKw^pEg$_neo#zu2Se zi2h2yDQ9RbkPpQuRNR?o^G-mE;Ipa6PN3Yijt7k9jg?&aH&t%?_U)xa=$w7daFusH z?Ot+w0jef$Ysu=Q#zFMDE06U%`HREAqIvE2d)@QaNKEhT_0sar2SzrK#4_OI8JeJd zs9-|Jp-)5nf_`*hf|#a7Kn)T;Pp2$&dw`N0W=HJAz3bSWr2vScNF_{_uyn=VzUsBs zyTu2R*O9t8%p{)8amI99H2*uk=Zq6bb*v@51q}z|m|!B9FZc~p+T>^OY8t(%`CiYP zdj`v#y{R)K@5O`c=@}qfMVE)5h%aX8_)AQ zskjA~OM!FCHIBd1$*Gb^n7*Z$^X@r!?vdyT{afnn-$wttK<=s2Xjg_dIRE2DN(em? zTOS-9|M+iC0}zSW4XE(+4q-w|OG}91!d5P}DDIxVc`uAe>N2a1lfFBu>dpBe@JvQg z0AYF1M`>qdufg5Z#PB|;MU7T(+p*(!Mq~H{JY>GoIHEjU|6Z?$C&1W1mLI039=h+o zal1M+2`7w}&At_GMtLp^<9@RXSTtKGc1s*sLg`I{*x$A9<#)|h@q?Xmp2m)#^im?Y zGK<8mwD;{~cK(^7bsMFr&If}jj_NRkhX!kNEuv0#pXJjn|M~iXQV;uL zF(c?ZDFT8fpWfZLcSywXa!9ea;i;;%O)Vm;5}&ESs5t}%oEZuz8>3!Dmb+@8V>~eA zR#5@3$=!Xu#je|Naf?4Jng>`+*UlZZ(qp*?75b$HT8EB6oK8ZK zt2$KVoa$i?RY_in-}Cd&NyPKb8OFKpIMv3=YNMm$X}&0C3YVG&{tgHJeKvdAw6RGA-yN&ql<3pU+#Cy6j_KDvR%YNjwR@)1hp?Z?=pZU5W|t3c z9IAYM7lJtVq}1AV)Gvk!oFa^3o?njOMv?ze&bj`{){gmg6*0oFtTeMKgMOE8?t?B6 zVGT@wofnNZ=!WoEsruOA#N&;O^^vh-+^=_g%49yvpUTvZ&Kdz)QZjcg?6MFJHW58)6r*2G=e}m+tVK=qF?2+taH9CI>WX z=lcVHDwNk{{kRj*c5jV*Onm$zcEC|L3L4eEmHA*qhCI-h)0Umt-v5!srr-gOY@lk# z(-aakWmEC@;sTlPh3G4)OKO`V+J$eL5ha>)1OLx_UgAi|XqidCVpx?TBSUC*0<*r0 z%SoE4^3O1MqDPxBX1?Z^nwjH5vIZW1R`S*N-5LqS(~4W-OR8N^y@M=+C8D;Zv9)&? z?7KeW>DOMP%GQd-HMzMB!ncD1@x?*_w9if6J;K`wD2x+IHNEKQbL0?+0*Lc4lFCX- zqauYUSMZL=rzv+%rCq+&+s4{D^xnO-#u14&^XfKt3LkcGbnGT=ZEbN<8Jh##w;DoM zz62kjurN(qSGSR+{j*yKA1yI3MLm+|%pKXKrcm~;Yr!R33V`#vOV(bYf(HrI+ji|p zH@6kK^Iun{Tg1lrUc32CHE`RQ8OM^fO7D~p8GYc;3**W;kJm3YTNPqsiia#4d0Ds` zXRMd^>Fw6(1ZfeU$o{VCike%9pWH}94R2$trws>eg8NN0y|D)lv^EVc^|Wuc`HTs2;%#A#idVflo{M@&LG4{(Fz1U_YEI2ZO9l>kuy-yiKROC?%|0Y z78_e_@bN-GRJAtJflH)hTrx6$my`BGvf$n4NB`X0MeW?DC?h z;J_j#&q}hA>j&jg_%gz`m?5Hl%xa36S~@idkFcR;f9+3{JFa-$caDqH{MWU+)WVfz zbXP_ia_-g4=j~>UU(`VB%vVS&2c$FDczM^?n%yM_ZP9)K(vYSRttXiLS~`1Mc7f?z z=b{I*8uEK|>LAqB!>@4}dzyRb%GhWPp$B5gy@6cXlu=(}T=6Aes*7{adENAXZf?4v zG3)rDcD)N@PUwW`nkyfbm$f^O;t62G=-EvJIk%ETIr;UPb5u@-K~|b+h#H=^7F*j1 z*al54a~3rUm}_~>pVy;7G56Grpcmm^zr{9|mQ0z{m`-tJpYUbPAcyNaE(oY0@6n}T ztFV=`-n5#&J#MOrAqpdwy5Sgo3u4Wt)UQ-_#)U%5sSSKR=-|I&os?&~bB>?aodAZ8 zRwThrVQXj)D1)D4D%TaMs~a`_{!)*otZLTSluv23gX3oUrCpYI^VDQ#l|zxbfX{t? zUu#Bz;E&2wC~6~)hAsOs_na1W6$%^}T`;K76n zyu~qQou2RXO6^S|vcWbBJetCk4uEn9F$C~_dWo=Z*97u}&oz^8A zM=YPXsI0~>*QH8Iz2TVqFgshtC_U!N3wNxs7)D$e)@ub)#&$s%{?YS+v^2=GjSu*R zdXj!$5LrD!H$`ynWzP;`h~sPja{0`rFU@1#e!DayGCt%%kBx4_k3T~Sj8lw&s*QH8 zJq8cG7~8X;7teQw_<0W9GH0#n;ArO8hh(me&<4~4sEGOXJ8jGU)O`|8W0LTT8>?9Y zdI!7MR_xK-*GP-bpt3BVQIjg#dUhDqZr|BMo=+F<8>4dM(!!N~!bhv=fz?Gf+Z%InBz+Vv}S=`^KZyD7zY ze(&*>p8t7Nny-9=>opVSz=!*r@(c6#WTocqq!Mvd9m)5mmkl_3;dRFIDW*n7kI4J8 zy@(0xpV50`g5dW2;bPy(25VIUm&y$HvA^cVI=jotlhzHALN`ozA}FHd0m*F)BJ@$?2#(y4LcU!mX`>8Mkd)czVD2kYG zA*pue+RF0WWCYQ^~pci&A^3@I5kZX3H3J6l<`jM=B);%F4=p+C7#Nd>QZ zsE=0d?cDE<09v&5^Hk5LP+U=WFEeA(VO^00}5x1*|9$mQT-Zf+iu?R$DBA_V3in;)=x5pY8>w?yW) z@9w(NVL>^iCkA}E(4c!zZ{>!Qn|89V3~k&K=(uR44_5#1=0Wrg4GrOC;}!o^w?)=B z&8y_cV_A=&6rZhkzE->VDsZVj*12sBD5xkkh)~=*^osL|ONLjK#w5&hQcA1Wzp;!e zF7!tGn-}#%vwy$SP`s>!OlmBN85JLg3IwJ7Uh5rkad8`W?C4ihQ`3|)L+W6T()!Gy z%Z-fEvR6?RoGUu<(HZ*MqQPsCh{gR=lhg+U*TULIhVA9ChyKlsVe5XMDr|h=A@h)?T$`#ee zg7wOZltL%ISWq%-b!hg@fd*C{CznnSdckC;+Dx6MXcJZ4f7+fWguJi-6q|Kk9Vonb zK}xsxM{9P^8Ij-TeH0HUkRBIR?j3iy*P(}%6{~N(wOpp}w$i>AFn~O> zVGKj%+t%r)YzzB2J3^!Hsg7pHo|o9?>y4dxy1L=t(vGH@a~Sbu^0F&7hVv#$6sf=6 z8YejpTIwhSlG~ou%qb#j41X{V@eaiX1&GUoRR#tZu3hT^e-b*{zbr8(=2X_x)7=kc z9?}UPInL}>_|%`z%bOM0;`HZ*7S!|X%OEw$V>VLbdl z=}Q5>rfr#j8+_xC@$!Y-t1C*0coFgN?W>iItKr2+y3 zG(W<&a?5FV^*kM4c5WUu?zC3u#?7fKg41-8C(0W6Hma>VbnW`}{@ptL-Y?yJ;b-!Z zPkw4ouGX$wus~>Ngzy@=pT40Z{~wQIn9wq(WV2F?29Y8_z!gYr&OP7Z=qLf(BmM|) zH{5S_AD!*_Iq6?!Zd~+gYn-K}U|X2fM!u3P^4sh!%lq~5`$Eldm{Qrs`Z zY`j1C`*ROgQQ6U*^=^BvzFf{_YG6k6r$C8*W;-sO-c4kXd~~&0Ueo(sdF>dV6vYl{ z?T+j~=C87+R(|Pt{k#OjRjZPx*PQNAvOlHO=<5~T@ZyPSc(QJS=`VSTFq@&6-^k1X zwwhlQG{1=MeNKB`|Fz(C_{cw@77pLA*4X$lM|i~QbWO>Tbu)E*R%pv^mRpf5@L4`*|E{g?2Fg2g{RM)IJS)W&wrz$wscf};dDeL=0iE7B$)W`+4^Y! z{S@I(Z+zwI9-OJ8&4z?d$wyVYe^ZD&>pm5n5RCMvWXAR9=CqS+gQmIDq;qh{Ta zmj+tPq~ESr{x-B%7bcT0L`C&w8q7R&#zdd+n9TRFbIVTWdM=q0+(hfAqe^Vh zosKX*-R1OEG)WkEcj;HSaRy81Xrk*yj`LmqOB(N3|A~V zsoYO$zh+fvj>>adv(P_Bsr%Q%3q1ZMC1 zT3&7cGCyqU`islo-T4Y!1f}5(m`>d)G)7rZp6@yQ(N>#+T=3`p&xL(+RE10@_v&uE zE*ZZ6{ho7fVbd+NK>fVb_-{olNKgZ7n0-=95w`=1_Out90Z z^KbUl7MzPhJ&(qL+_d(QhUwR znR8ER*v;w&HEtd`8Ri1Pg-fg7Kyx{)vX*11d`2SfzglMB@4E!lo3XL`;V^I}w;TS% z{!=#40KD3(wX0UGitaii>|{@``6d$%%FIf2V4CHp{(GPZ!-F?&+}LDqZ*MwhS)+n% z>9?37M^(W)PM_`tJzqPj>jT60VXv&d)$BG{z1m^xaGf`Aa&lH0+ARw(ymlU4DtWiC zX7|R}yqEd;$AE2wmo)f;&?3`k^PA8-vJ-Nn($IKYeWjrb)ugnk4S5KjAU-@3HkwLZ ztzRjZPmco~g&a;>Bj?@8-1=%cN7-u;q<+rz*^m(7UdG>or46PY+3(-)-f$#-bI1{vfyY%a^ea>Wg|(#lrkkuJ0c+7f_&LyX~DI z>&0hkF(Ka=9+I7Fvk!Y>A3V%?3Si%*;eHi)P7v>&O$5p_birymhko(r$Lm?47titEn_rrTB#MKW2Ym zM&u?_G>M45tqUXx&{Fex;1{nL3~Kko5XcxMF| z%VqBy{G;v2ksd&l(*LGjNw9xeaC$`^hP!x!Vu2uZIxy#-?Gsckom1DVxjcP%Z@L^X zHqn9*Xl{5ffokHXMOI``nAmJGC_GMhn`^vyM3W|HO!iGNp zPsaMpq~gzq{M>Fj8bMj5BAgJVoij(%0Q|r1&cv_BynXxGXY33jp^-sCNm3y~MM&FN zLRm`6)uf8e=ay^Oi1 zzJ0&f^|{XFIFI8vi;Q=vO6LY^&HquYcS)TCIx;4ly%~1WDa=gMmHk1^+Kj~=>l1JQ z{aL#22B@1xu13ysu^o`txuy*NSJyrxoIxjBlJ(O#z4wa}79KY6cp={-b3dSKcOz?s z!gc5W7|cOVUfmZcUGT!XoPToEZS?gulO+i+eBsPkQ(GG*wO8FNujRFtfqKA%vcR)% zxPOFcs-fuH3TrCfY*|o!#nM(KeA9f79oDHGCkq)6A=7d>8QoX2M#%~?D_NEv1|vRb=S~Y zdC~uBH@stEY}sVL2UOJvBqo%G!4PE90g;%3xxwjsg!W30LCY={N*Bf!TCY=k`6Iia1n<@m2Rsp z!Q`MXwTo!pa;x1kDl{htro+dfN`hwo>`G?|&LvF`L4yig@Id&$=xU07i*Q;^Y+`T0 zv}x0raG<;vbA8axjA=2H_eJ{8i;Fc$g5(3b7g4q#d|JoBk=@Q+N|P)MJ?o^1B_0%? zOS`9^$>ulgS$R1orZ~{Seb53933tfo!_$I-wa(7N$NIjzy`a!-^pJ(G7UhL=n9 z+VS&-=j*b9Bg2)AQTj7c-3f`<;#NS52he6zNDmX zC|p7t!EHC6H@G*UupQv`@w*+6#D(npnSGFOn*~RqQsxlm+7S+DulqvfkJkWMn5ME&5SED@S7%`|zbJi&;hP=m*Z}*HtyQH#O zzjUVEfp96^%F!8Va^s^;JvnjX_uqS-$?nb+bHG;Rc88lGMlM{a+ZCVqIQ-sWh&sr* zhqE`%BaG6?a=Wk4Fxyv$H_1dXZjq){(edCx4@e@pFwO8^lQjj(1yn8M%ddiBjHiE9 zbmgja^a5W~H6Oy;+4?L*aawKbOa5>p}k*}@%%FS$6Zt~pqv~l{j#%mB#wE$X^ro| zlh#rvKZo`{0MN=xN{|b2Ptb|7vLSb~Vu)ViPx~HK&gGhL`9=SECsHa@xrz9vV{p;{ z=ux~QYA>VTM;%u3I8R2WCPv(Fn+HPPZGZfTtDEBV)itB@f5gxUJ~Uk4QRIAvd{;~^ zW9ljz9-3P+qS?bu@ydtw-vpV)S39l(CiuShM5m+21B@#KhC$9B{GKSCw`mY*^Zh2Q zUUt_9@m6V1cwBgAk9z4I+fJT5pA243WCNj68+_R`9@{G+xe+~3t4wBdMcng>M#+ym z{jy=3^J~?D+OE9K4%~We=7(0vV_MLW*4o*@7yN;T16GpywKsQdrPq12wo}pbP$Nqg z54B6HO3_u>9?6WsG%BtqB}GI;tSdYH;YONHclE3zn8kQ8N1&RYht*o`*Q+yEK`R8N z$E6}HaHskR@}8)un3NK_gcz%KcdYRQg6(Iug0+fx?tF437ayYx5e)}jny07dYQz1F z84%R@u7Fwch9hzTF`&6Y7z7z7QQF5g5VP^+V-7>%%y9#g>54HJND-~voFz;8%=VS; z&)AbEyMNC^@P0jlf~4Y#LUp4kyadmy_-9?CFpWpSvg2DNN}WZWzbrmBb}qHFs5nwm zvJY$X7$*&aBuI1~eq!Oq5xYL*xpEan^TO0gW9cMJ$c5|`aT+}d(S1egtX|5~!9s^e z)3c)e(_gbD+c~h%8Y7F>E`aUqba zq64J_^s&AzS;Hz%Z#?^Fh9Sw6i4hi05Ur^QvMMTv;e25CWIN#zwjR`ckR|)@66lGm zjE{jQQms&aK6~-vp6BrCm_<%eRVC~Q2N5j(t0@a!N@^l?77OpFBg$cVG&fgxW0zxF zQy|BQ0Mv!2E&)@4QXQW~iTT*UMaoW1C1faYILN~lIn0TKr?p}2THJ^wxVFt{9ZenUot&hY2$JJ2 zkOy!NC(Qv0CbyqhcJaP&1Vb7MbqymEv8IDc2;Udcj}!Fd&wBs#3kyi}fBp3kah_*r zgSB-}A2tWr-gi2$XA1kwZIBrDl<;CP71+4AMK z@D9)wjg;4od(nEbs0sZ`@hQ{sgKVn)7zV1_S%QQwpbR*cFvvkVPxw24E6_HJtPb}% zd08~bBj!DbPsp8aF4H!o_B!!-&E;hqAU+U~!O{=sznjnXp;C{Z5?|CyVrD!#L8!_{ z1V92oUTe(2I}q>n4Gi`(a^wT7!+M(X?Y-Pm?G~eGy@UZrHCPDIixCE8u6W{!kdQqE zlWOIwrn7!V1%_ixFgjvrW{YB|t-UEN-D;pWy8+bVZ zv_F4NakNhMqHfgdk|hPZ{2VUE#eIlT-__%?H~Emv258P~xf@r41;50RJW+hzbTn#bXSg^j7XJ*gdM@gGN|C zC}E#7e z_lD?2u_`BSS0JmJv{}?%pDLhm9z*&^5eVcu=VTLcjhtW#A8BZQ8`)7 z^TelZTnC6oTW0d<1bYUO%^@4rTiabZjlnY=2MT892BXnFbBL5w!_4k&kg70}f*%u_ zpC6)T-yH&Zy1)ZL|53r>+WE6@rigrEh;e7_ZTNxSB_$>tZ6V9&dEg@Mt`6Dd_3OfJXq}4@HFitfx3HL|nZ(5E`Qn9?+EHv`TuRGmjEyF<+7-;E-au z7jwD8BuVkMKYjY-GA%u9u!n=v(B9IHs3`a0|KK2lJ<^|_?B%Va3&wmdaGjI)EA=UX zhDfY4+Gk(-E2;;9y8xtp#HIoZ3<(t4y!hRtKaCS9h&S^p%U_;^aTgU_cu_6I;-8Mq z9A%ow=2!jp9w8189$%4HSl%JU;EKkF4oByq3#&CKaBwyfk1d0@)RO%=U+>%gJg=F$ zfC&`n1^vu#3Qo~464MsWE1#~JTo!rFtKTS%t`a=<;2Cyfz9crTiuu$q9uyh@NFLn= zRJw8u7CK9a((B!h^;A+}d)w7GlB!xx*q7PQ@Y0|36^6lj3P zh|#w1ZCwR5Xn2fqPw`wndZyz&QbexX#0lEHrld2{(~63UM1;bxK|FYylH3{G?&(dRt9!rY;3H}=Qj&Q=pZ!O{qtKW+CxW-V0|xZWo0_=zipqZrrs*T z3`bQ2PNXw5g~K0^EQ;^=y|D)i3PH|d;8uzXIOEi&Mofp$E5 z5TAQ-KgA=2?JyR~`w3L3xgrTsgwgX&Y_ifrtvZx=0x?nr@YZ|B+6a>`G<4$EKp7X7 z33(4`QIQ33yUdGk4)u?wWZW}3=Hf*+bPv%1ly%5kKd;#X2E-t3vq=XE^7&i0PNM01 z_M-zECU2|cuQ@FpM-l=X#+w#X*8LQV!i!*$A-n++y2$+N%Ln~6N;p__l)h!ooukt|aMpxw*|yK#t^UwDzVo ziX6uwrPQM)5tkPR6{K|$hEdkvhrajh*meE!;nYdDi$-ECSV#dt_0;^xto;DbM4~%g z&}5zwVZwu|lBSfYJOV%z%|wIdE@_rD<;qf89N3q5fIx4wy>`ZMik^qvFFR~s(t9BD z5%mh~hF2p7MtIilaXjZ56(k=uE$Kq{o}MuSJ%Ycsey2^DqovhNFIe@?$PXDQ4Zfd? zu#DkAlf0ypS#@tfsoHa-cSicl+k9xU2P?>hgMcvH5mgq`x(n3DDrXJ>0w|q+A?X-? z>2l2RQxVrP{o1_@$As{p(Uo_1>%_pb83PhC;rh^7LNDS)LBYKN_7Vc!pui3L{@zmH z1mGPXKV^)vzt=}g0#V19fk$QY_ZT?))t!GRBNb_9)%tK=kZaLkkQ#I8k_cEYU0b&Dj{*Z)#R#3r<8G?#brij8{c8CmmIO}el62!nbSzBJa3(*Jf_gi)sb zndWw3TO!m<<6i!h?fJxEe5+fP<*&aEW+V%i#40)>WpihqW6o9Eck~us{guu9c3$aw z(K4!SmeRzpjg6zpg=-bp0@(3^F#Nwuxg<<3Xb zJD(B6~I%_ z0yq-);MmVtgL5fGLa>I56uD(%^hv|htGAWuhmMO$BRUDOC`4|F@`o20c7%Ru*)x)B zPhr>^x}4qx&76>s5F#+}y6ry7_2C9Q0t{BJJW5UKE(Gv`%3t)my-ZN4S`ReW4x3@VAh#{V>WY@x-N;?wS^-DerE*73mP97#G zm`v%B9STW*1r}+vLhpl(Ca*g5`I6J)VEs^8Kj#A(6?dq*Y^WO033mZU0zee@d17eH zSXbOG@JWFbNlD!W@+`9$9~FxWr5i0ah{2a$6f|Y6ph>QueZwe`L`$5oqY(5&c?^r$DAAm-aNuH zesyx^a{0)~mk%-5Nt9K{9tCfbv);ybvPl>sd4o^1<#x37%k|x%-iP^0SZ+p>NoA?q zgJmr*jn7ww?0Tsgp)cS{p;xgFa*}9Ok{oYw0N*_FGJJfo*)H!fXbz&qj__YM&=W4YI=<4+Ff_luW`}xenwO_%f^Vg_YlEy1 z1VCq$y-cHDEVrVRji4Q~iY|=umHb-rM`hYgtKNU6_U2kc1A|W9&plI9eXq2o4H|a$ zdY7p86E4rv>Um`Q)bHg@eGHEnUcMn&x5}`wprF9!vgYslkGq{1w3D|=8ht>S#k1Zg zIzKaONbH)0q1na?aU|Z6-aQbu5+4b@a|!U=4tW_0U<$h%u7khNn|6a;7nq5OvA@b0 zFmnUO>^qfXmhOum^aFsBDs&&NO7e$endS$eFGcnp_JZ?=*u?p^uVJ8JzmuAWQ|Il} z2W=8SLpbgS`4xs{20?~Opn&l2MeH~s=}O5sB>3)U<8r)}4V6ZQIx=c9`E1E{tlVy@&T$j~%(ca}&z2-w5JAAl1kNvU|6*yg+ zlt&Nh$x76OCw|ts1|y_FF*#OogBQhIV8ol7M`N}=0b8D~OzU{$^-gOa9uWI#VNc1R z?Z%P;4`_(X-(>pIE2=809~vad1hWj2cpDFknu~xwltm+D4W-x&fv>Ev{qU@8c%eTY zmuCq`MFZ5{nBxl2SlA95gCZvQz;*CdnxbHnMe#-H=1K{R!>7%WGwZTng2}4cWkA zvr#1HTVdA?ytH&j8Kk}swo=dzAQ>=)2)5?B=~u0DW*|kPlD9&;bH(;n?}uo+Rx~y zQH>@H>b%3o!;kegVkE-V6|kH7u_$L7m~kCu9K~t!MCU0pXZB_v0yix{mQc?)4CIQ& zGXtd_MY`JaPKBA>_CbA#i`4F^t+dVHT)1hxJER5|mszyfG}0e!P~AIZ#3v?2bDoQjO{8>`H_45HltaNh>P>eNaIyNv8AU3=tcqW9^( zd!riPEn?{iT_10cp03ILb6c-Hm5V9b%bQx8zNXz@|El`r%qZK)dI&?Lxe_t4`x|5Gjuv~u4S>uZ zht8>4Pv+QPh4Rtrk2`j7XmOnbby^o#dwpa;A4P81qfxehz#8E1)jEE`cNh5R<*lcn z<7<8bIm*|I*wAjq1{UO}prB=<#*4=mCJk(#q1}14c!y-Ju?`$15&2wq%#r`NYv7 z%fahuXKd+r@dWA1U_JK+N2k^8&4sJSyGjJKys=rnYSp7@SuxinzK$wOJu=aM&?ok&3lOfL$*{?q|0>v8PzG3p@|ZELP+R4m)b*?G73AKaXFUB0RY6M1 z2=uqI%@31tT0YC1uIS{|4ghJvlhHn(1eF z{<{@bh6m&9T-f(O%f@+=?f-+#Zd4YybGOId%wP^-ZzES7xltlVZ{0Ip1)<9zjp4EodM~#`#XTvSJb0K2FBB3KNwWe zFS9Jh%DB(9la3G;R!boR16g+?P65aP@aZ`Y|DkJYoNYUX0j}~x=0`O>KXxK(&#ot( zeiWnTk9M#z}_}<)PZ! zJ)Iy4yaykF({z9d;gTiU8j;5a{$4;Dw_P(iwq?Dt6bz4*N3E5mnr6v{<+6)=9aCU{ z2ujl6!?_z1>yEXJ;S>~I8Lf2)1C@F|sU;UoH!_7OqxYw>gST+0+{jgfA7tPLx5!fs z&!x@Pa;;&#r3!W#Z$4ZT>7(iW;AG8S*cr=8{HE%{cp)PTk^rkgWJkU;9Cma*T}^Vk zE~qA7teCP$F}Yl25_#@Tg!EE4md5NJtj>>3pDyWNk|M6op&ptY&jBiDwwjGuBzLYit_iDmdHF3c*Z*0z zO;fJs!jK9u2d)kkOi=}>=FA7iyL4k_9?z%%YOVTxOGBz|ltR*?mD7E0rCYfeG)QN! zGkkm|yDIw0eM6@^3&z~-zJfs$h`L$Fw<5~H!?+tN>}^>@z^OY^fp1#|qRK&qtB&1Yzk2HOcl&&ysxd0` z>8pGC6b?Es4NI2ogqP3#SkcX}A6=XP1zEuvf_+@pA%j}T3Z#T_nK{z&gE9XY% z!*0_k>=+|CFQPv{BJ{n8{CALKixssuvmGkY=XAK!4r6<7Y2{7zh97DR#_=HDD!b1D zljAd_`x^iHYn`wEm}8De-vmqxdSzBo|qp{UCy_^*#tt<%g(j<}S*;PZ`!7gs4OI6jtxV?QmQV9AshMB-5vF*D{i zQN6t-tmCwiozdPCcb3b!T?m~t?DM0L^iBPu5*^UGk5fKb<~QY_b;J{A8g0|1*8-Ot z8((=J>Ey=rUHEW{qZ&cSC}flDAGJAWBo|_|uzOz1fe(HBzRpd4`f!EW?*Y2iT?^-( ztIrR;2}Eq8Drkz7)gq19OGi2`{dyDX_>CpkyyZvu^X4oY_0>J~%j!RTc%9yF_PxFB z)qBwc{UrKly1E;wKl`i6Bwt)@X54!6>^G};1C6jZvcodo-CAn-22vE#9m^lpXZUL@ zN}kMq?HSwf$PGBnz1o=AGCWx)|7Ko2ABA7fI4pJf_}mOkYwo6ff1bvjp$F>H@v&dW zJ{A~w^&qmge!6l~qjVfNZP_8K_-iXBPMn>&P>|fwb4-2nILLUeQfO8edL3FBzCJ9K zGCP^_W_}`T+V#gD4=>qYxo$d%p)2@ql&?+nN9{&54Mf;}=SCQe^ezX`qs3Is9U zO`m2X{6=Mm_O@%dV(2y2ci}<<`Tua9rk*?X6po{Mh_t7j{sjGB-el6N3OZ(PbyJjU z;`2J`7UP^QL8+UQOprM@MtRBaN{D=$92+~K(PY;6S))_V+*!+*=XIl8*+t_#-K4`8 zmSmNU51e~^a*46~iP5XyeyMzN%jfK}$UNzwIM2{Uc^eu+B~(*nT*4;1pOcR4SgJkU z<6+db3nEG;VFd^y4r*rY{B=yc zU0?rNXTyyK%IB4)m1=qj0+Y&%@fm@)e5@w+bJo!fTolvI-+rmpLAj8r=Ty&?8RbZR zW(#??v}csDiLWMl@Pz3a+0Nyg-8WR;^!M^sDg*Sk+zye&qO|I z`x=k;3NC4qP@O)Ts4v#;WR@vagEsN#+z6XZy7TQ`pt}Xc-f|(j!4#E(@c!b2=tMof znZMc~XQkBMo27kOrEYiVLFDSR$IpCq)LlZK{tg4P#bmOD?67W7tVoK$3I^P$5U!}f1#!Rl8%;kh94VDcq-)?^XzQ8nj^<=N|b7eA-o=Q&p&0lL? z^$i}PTPge9^LWshaeh|27HTf~vh4KJy72Euo~~e2siWXy-#@d2e$`+1zQLWSw7vRH z7L_lSrRuyJxuCU=U4 zg0-WDVe7*=x^gYLWjNMLhEAV+*rq8_O9)V|iL{;eGi9aC4&r>srUHNIxuh39_!2bO;&$A3Yog#rGDuQ4`ZYAwF=*7e$CLd z^Q)SbxyPxy#aWu>nN>S9fVjjGOoq@!o(j`$#Woi&kW%t5EhPksfF<{L z4$}Q4b=k$z>J}I23w1rTW(PFh(aib~S#>WlC#1r)UbVJLMz+JCOkvuVz}8mHy3zO3 z6EAJno$AP}ZrNI&0h8d>GOqzbqwiz(zEn#??s>!3=p)OY-k7P^F?*326)NSapd?Wu z0(Y*?P+-&#hk{S4DCH@7N|oj-J6k8(9V*%L>{%DphLvPN5duJMnHqk2n8Jv)^p~&37q2XF&v@q@sbRljRhW*3^tAW|Do?f>dE`f`ZrL{K z7lnScVcR!%oZAsvnbXNVq+e)diubpzDW+z>EzSCxVNk#0nYu=-r%q;#Tk5U?=TXy| z7VWKF`6k<-Vo$Kkk#7^A)?pdNESZiP{x!U&8Fq`kO(e6*RNvNDRs~({Tcs)eRHbsY zpwMwvp){bp%FEBUKW8*luC?FmD=p`OpS!0tB?jAi+i#rIG}Ggrkh)+n`K)}gLGlg_ zi1*b`24!t(f6}h{KE%tq$hPURUA@7!zCN;#;pD*>N$$Eoqe3m<=l3gfBr}G^M^<$m zR`2yKq$2CMWy>YI*)u+v+7*;U-h9$@t4mttG8IpezM0kz**6zEyZwU z;Vc-e63iAK(-+mhc`6!0aHB&73<)9upP_VykoE!GD8T1bs|^{g@XniEb_tN-KxLa9U0(=HrI~L2}@x# zpr~OMBX|n5x9|cwo=zRSCy@Cxcjz9a;6B)uaa(aJfD9*Qr8`BYS*M@TH@eV}w%r*g(@)&0e!X4+1nUqA?kUV%cBAuP*`-P2hJBH-v# zi_dSL)u-gkoCk-dTISeXKFc>3IlV|-1jNEEEKg~A3DwFmuA%I}#|fG-{dvxLAG|?~ z6wHRrD>9&dBLSY3GP`pJb|DM-Xl?2Ck_EWje zJ!Oau%>se2~7dr+KqV?nQv>;)t!u=Lz=2FKl?IkTPxcpjr)hW_g!KNT+qMzL7kD4hkUyVXzdQZz^wTD~E8UE$Dr*m2JuI_tx%Rr2txf3{ zm5S2jLXLghb7ae)RPRAO4;4A&1b*mJX1O3$&f>+zl5wLidTW1sd*2}Rm#9%4-xhR@ z{tqwn?&f>b9b*C(eXH%|dD$?g)n&d~npsqG`-G&gJLj86esp=W)8U}c*S2W|l2IM5 zg9og1QGTMM;PrX&B$t*Vo!-F$*ZJ$~<1DQ~Q=aq?{}w*KQA=~TgL&yKX;ZVJ;s1$k zfAKc{`qOmAKLUh*UIPugyxYGH-@krhw?+Rhf4}R0|3rN6zti=SuJ^xwh++SKe~2oJ>2v<)5*>^eM{&q8 zg`30veeuhz{<#8Wo#@g568O`2LgAxrUjJP3AMWlN^7pm9yK5iLn2X2CK3MXvi+WJ@ zpZj}j4B3n?<)C=4%9EkX{&kyPZTjaID)_)uf~h?BVqxn0^Z&YGGPef)bBj%tP6qtf zD>NV3|K%6^U}f{O9|!(TPtj`{a5_@BSu zT`pd0&Vxnlig)%D^1H&Zg4W1YAdS=BIP9vRz{X2OEbypL(eu;3lDe&a-2eSjdlJPf z5!!KNEq5r2xE$3@FYqT)zy4SULY)P5k{p9IPL=zI@M*G}c`^_t{->*D-izaj!Fl9^43XyMBXf zdh%c1?o>Z#Xa!ERVap!rUG;sCc1g3)H%U=~RZi%DNu3}JXHVp!aauo4T3h9e|APPJ zv51(szPwG|2;POrP&Kvb<+mlk?iF`*bfRa$&ZdWy=a6L=yXi%D8Ra0E%n~w_nMDcNLb3}9sg0zJWo8*-B^gSE z%yXHRd0uP%K6gF8@Auiyv)}*U<2~MRYR4DDI=79$!G=sEoyS&tOgU4yEqe*&uu2A z1$@cpI}?+WG!xUHArq5QBoh;#-Q)b<58xZD#;1=TVPX*fds37Xj<2k;JE7xX4 zx9n3JX$fCsyKz$EC|mC;w$;p1x~~qW;}w}s9#K8-`fa#P+e5%CzkkvD!sd0NC!`0| z1K(%f8cE1*Yi{$H{w7ac(-zdt^Dke`$;8l^X`rJKqV23OB2?cRc8+XwhG@ zX5J!iv|zk=A~&Z``RtK@|I@CXZGG<5MSDRCL!n%HDb81rQtG6q+ep*za5^RyrWPvo zZG&!chs#@5)-}84!h?NHe6RR1myeaL5VNyu(x=#$ys?kj>3@Z`icO5Qu${u(DKeCH zCAQ0AU|{Hdwl$MPwfmQpiHmN{Juhf;4X({S3)y|78ttOxV*mX|~;!dA4{dtYk=$i=Wqj1M99^mMo-4 zrnza1ai+zaZVhrW8k28U){t$6wunu5RNc9b3Pvy+z zr;Fw?Jh?g-e2qrZa*Re9W8xvqnk<8%N_-^q5=Q5~r=7)j-c9~~&j8iYyTUype`1I^ z=WiEVxT;W^9&~MEzoTD{+BK$XSGTst(5u5Pg99D`6!PM@?Po<#f%hHU{p35Zyw6d( zT1L)w>L2NSaQ5eG{(I38Z^4$%5q@XGTW7%+`VlTO`(LwhtgDtYI62@H>|3dNWLt{eW4-sur}#J5ApZ^{WH8rdSxWwnh*d+Pr! z3w4v21IJ}zC#a(-`ZE9c*uz9ls*>CiT5Y{KW|9I{ZZce!Xmns)vBfEQ`GwiII+@~* z?tj=j_>%s<74v-wlZWS2nGgG`zOuL{&AC>5tGaKb5zjJp=8`7vapn~i(>nWRM=~vq zQ>QVZJ6|!){nJa9Zkn%^&DcRMhzHA;*9M02n&=Z`!U>pDMkKlj){x0jccT!S*jeaWEH@&_j zE%=(BiRa}&Z#tNg|%ee=#T>t*5XPpSf+oYXA&t4+>AT2IVhwTWf2#b#mj+_||* z(%}vt7A;%;*ZlQ1mpNECgwIO|U$o-gDQQ88UwEOQ|BtKEyK?nJwdPL4L#=Y=Z`5mV z&v>6%p)rWAzKoSs(x*UV!F#l^Le%I`nXGv>$AU!osL$ze3+d~M2VUk5D``29>r(#s zpJn*>zbs9y?~RDN%l`QhYq@HWrkMEl!^@N;?l3(po#mp6OfN9;O0HrK(P0f<6BO%H zs&Y#Da;DD9{H9@riT`fq#KO1C(8Pm>+%tfcl)?ztL1fC5}I$zvTo$NsOQxGkE<9)t&=9{uGPV2G~y7wzDbb% za-m2j|7!lNhPo`;sdb70I{+H*4vaKTyHYR*Ow1E%Rmu{qGsq~k+@5~?7 zhw7hne4|c@*t;2T3>h-sBSxY!3d z(hA6`8zNrxXgQSFTPfL{yu=k^!J}Zr?d!w2N8YM{^=PY^bH9Sdx^ii0jmbPp83CFr z>>rDqXBj&;mc5y0B@b6X;1RJSVrv}o1m`_tJ(U?p#@eJgB&y4#@2Zg<1qb%$)W~Ug z4|9bC&9=s!66G0-uxOdF+(ZAzhc1k6<`*cynxw{?43d4=ZswS5(^T z+38A@Csbvw+{h!%@w9}CwdSElyZvt@inHJ0&0=9IyX@xjC{HiHIx87bH84qvT)N;A zpr8?cN#dB7*Fhm6p&QQ5pFVyp^=0FZjEwB;Di6?Pp)%j}5t*A^y-~L@L2HNBj`gvd z{#4-bJ+bw$+@@_jP2>-XA->ivy$<`{_f4>KHti4T;S)OY?Dw)R>5!g#HP@0armUX) zr#<%0@~A3l{-f@bN;z}x$j9u_H!kz^R`HO7tNlkD(gy9`^h#gMZqC$u(c#?b-5y!c zbGXz=$EC};Sv;Wnd_{$vVav^@J@t3h(oE|&Nv~XXlp|Xx!rHLSzxhS^qY>pS0rF5j z%kpIli;Md_W+W(~BD4EPyd#V=-i?k*o<4nAT~jmb_3I-_F{HhV11(OeDJea@RpFis zKayVO3QPkGnn&4(6m!C8=Z-6=@`&cklCC|NmSg&-ZZV`?8ilJX-X_*ziDXj~T^}?LtId zko@d>`CS+IqG?Qw*}zKz!;LG~>qR8IINfd^nKe^TVP?g)?mIXC=gZ=&IGlBx>;x}b z`);5ZwK>nK(YVgXg;lxe@ioX<*xC+!N;ti=G@!!JSEb)js`>P3?Xzdk?uLXoP}>jA z%*;GLzf;PKk(+v2Zc{=|PJ{t%P+LwxVO=O?S+3&%*GKcIz!3+%2!DUp+Sr5D6cKNA zH8o~D{_4Kx7gN+VG%^YbPQST)aQ*#Rr5EZP*=C-;K7=u zr6u;3R69j=TU#j+(%fdV+Q;$9$${o_@8{+e4ZNl|M5+6qO`iHh(fy~f)oX$2!uPa7 zmOoql(e68u;-O}3dd%0}b19sXH{i8)7*$bQQu#^@ab}-YZ`FNwQw-ZyP@LT^*+*sR zeYFla*jVA_km-{o%i)X7fUC!5s4F_7-^o~-pL~4K`OB9zD-SGAgq_%GZf%{Am9?%q z^F&g^pQpANXYeOl6yLMPs-4(cmSIAf>kZe;^_-XWH`0>eMb%LP8-vju97($$ah@dth&tsTy#&C^V=zO3jm z^RgpYC9duqHoa?+Z}oCJL5jiXr2j_|?mhwkn?w0c*}*-Eb$g>r)-5&>4YNMqGtS99 zmf|rqlAlU0d{)`lX{i{}!*Hz|zqQytDMTsFwtn$ta9xY&KUMm~3u2;2vR`6-%L#1p zP2E>ilC?5EWOjw&S7*2L89LANo1}KUwM`f4rUFi}sVnXFt@SxsU{Sx;C(zFry{oEewyrlv|NFO8{HQYbo?E{Vp~ zQEkg};wSLf(a<&pujLA`*NZrna5&@AUqAa@y7gjnj}bqf?$eJu!cO#RHPSqlHPJ8e z_eJUe`yKgZg@BP;z5-#)wl>>+7Exv1ygJ_u-eEj!ARI&}X`(tM8y<6X>tmrHtj)B8&T4sS*Y&sRv1N+0af z{!XQ$3{k4SzP=n>T(a`=m1SjR@sbm}ckiB>nyPPW)6mlsBKGjh)KuC1`>ThCB`S-`+i>1okOCBWTXBnDvJ5~ z_2Q;Yo1VXVm5`my+vtR?gKZ|Gpg?JD6^xFKRt!eJ|?2VuoA(O~N?BQ15|Wa}I$!CsWh{34+wB$&}Qe+cKP}FpXB95xgH4VQKUPbN?2P|RD_4a6MQVs zd1RA`i3urSUTa4$TX&jnURlEF2y+XI`0VVj+~pqe9@Ks40`&_dD$R!fZI3;=<__MV z^E)4?k!SzlyPN?_T@)2~$b^!V{SNXhMe4-@U(2(Jfo#v3-bl;|j=g zpWcaLzj5P+w4B@#Z*Rsrk@Ff2TZKdSNK1E8rCPErEG#y!4P1J+Mf30NLH~g-XTPu+ zzdwUE?sBkpkvge-W+p1}$AIwin?4<_>A-uhF8&r#eLm#^xhuAEblx@ZzOTtpU-Zrg zs~?=|)7qle=(IPRXvwxWbClmv^Cdr;|4FBlh%U*>#^!zg&iv8ec!R8LY$lRaXn}!& ztI(tbTzsvWmNkkRZMv<&dOnhKWGL}?ybL~nQMb<4OX=Suou7DrI!+!xEEG)Q7R2!R^`1bXg;&yRYPR_wUxMMV7BWI%DZvCd_Vj*i14Bg<^t z*-Cu6*6!gv%*rpG%caQLlzKX&J99vhB}INf-HtP2FUKd-fEPxcg-_bFj`!(_kIiwF=PA!hrfOMmPJ57;L^jRj*fe_Y}rC! z%fKU;9bIL9>@Q!w90M?oy8h-A`Xre?owmYwBkl!u|sY*A#5S8^_0zWg*PDFFKe-@}$1 zH!HlPuiyXejpbF@jVdMH1S`nSCf!i={d;RH)YUJ~?eD*KHhF!5q+#$IZ;Pzk2lw=;T)Ela4T;weYH|k#TWm7$QG@SUik~sOocQ%6>)tA!5SM zntc8bR5z~s2j((nDRvL$@M$J{uvbtTJkTdNhB z%P#o`1)r|9|81Y-1xozjq4tcbeUe6vruE;vZi;FEPchTO%P9?;;3A+hRgsz@Iv15ZO*t8nDxJ00d4G!+yzkmPLEFEAU^eoKc zH9-|9@6EhHYl4qqDB|(ibQIUn7H5B8%)fuI>#wuFVV3yNCN0t0>XaAne))2N!Mzjv z=dXqze0HA$|5jV?s;}zs?%lih@qU+(b=%KqX=}TDpUt+;%FTVJ(BWC$-!G0mpWMoQ#1XFhH>Sgyc ztGz##inaXw`E&iZZ|^%hMa#>}vC1DmepCg`%DD7FM0?I`9BW`?YU+og6W_i)%6;yh zU&T>*1qE>H2WIiT^R#=SSqhGKX6FXn+JC3Koh|aG)r*d1#U|NN3#z0!)ElM*dXz%c z-dy%dwTy^RLsgtgcxu@$P$H)xQ9ZFI_{;SRvrJ-|<7Zgr52g-vD{^I9lLs%j?fzqx zJ`3M-<9p7Ad8;ibmC4_7vrn%OsFq%7R4**I!zBIpz0&F22No$Q`K&8+9sE^4?k&BU zX{lvDV(Yt9Z=5T4nTLaaIA454<2G~t$WiVqp`fc#d(4VGJarq>dKtr6b&*j~@cw!8^Q)Ok#XGhz5YGd}(r_3xZEw_fB+$13IV{oucP6Inhk+h_UY6fEg%sqqc zjaJu^jrQf~IK<1C^rmal$L9w~7Yce5A0KeL4@sjr%Zw%vEmFol>8-8JFl)Q&A<}8v zURZ^4TIF|`?q3(C@3j_P?xZJCEf%Hj(D!}=X)?M)aP@?T>V(Ja=G6h3=ctR=7&CNz65KKh&W&?qTH6amX4e17xsH%W^-_JJ9d?=sI07<8~nC6Rj;6OsciYY zC;ftL`!2pv%8!vBUGM8im457eiq6CQMA2rw_02k5q9Sl6@$7H;1qH;*jKM?9$A*BDDu?Tbe0M%k>p|DP6u8S`5Z=BiIP=Y$qsnpKyVKm* z*f$h@Xm25TihS7oEqg>Hsmm@Bol7iCG@?4J>MBs9EzR}L5)bvrJ$S}=p$cPkl!^CJ zto}xG4f-2`Diquo(r>Y^+ZO%ih1y%WmHW8VcyB~&nOx?N{F5`{HM?a<>EjVChfLFIvhq@_c1yOYiDC(V(69uzr+qp z%PqV?cw)Wec{IJRU%%#hP=#HnZTscC9$7qs&@nN;lgNCN>E@x% zz!FBOjHQ*;RYOCcn>Uw%bJBj}EQ+o(ls@6%u|Hnxdtl>OW$!q*5Svh|R47&C?^Moh zkKmH1jsG zMq|L-mek9CYfG9GKxsWsOPifsb=-7Y;gZj60r)6j-au{4v((fOv_yIxxDKR3jMVlj zDwVVRNn)ZufiJ7J$Jvc{A4Q9Po}4_MwE<}h$q3-EKdVhK00p@ShSjqVD+4i=ki*WI zYzh2!nW$DE3_$J}!4`#^+V|F@V26gDWoJJ;l~DHi^ZK6>*Nxs{c2-}I2?BZ~oCRK* znv(m~__xIM?ENwP{reMWa7V;c<)clVoF1TOo=W)SlA313kE!AB?+?m38T2GEDG58v z?b^%^Y^g6_j=H!gfN=Twtw7Va(~E%6w(~*v+<4FVog2P3HbzB7jSIxRr)PA>4B-B^ zL{w*>UjUbd)_N7axBlaZ7~sY@0jt4taiv6=(C)VUUN}4BI8bX3hy_*Sro^ zF&$b9TtcH2Y%_CnPoSY&&qTi)>W*1C!MAz^?un?=Jl#NYVu3;A+VOp69Hud1!#qxkV( z5U~j9%A1ak4o!$HVJF_EwWgil2}K0ofSg~iBg&yxC?zbI5_yRig#=rZP@IwqJ5d|`+?wR)-8A< zfG`PXtEinKfk*CC7}H(-4{ru`Ej7G{A!nn+S%N3s=P-Z4cJOrHim_0k{gsPUavlED z)YguO3$POmfY^hM0B{UV2CO_3rV9FjIi}8|Qj{6N2*^L1>hD!TWByO+K@p*Yb&(4ZdBXw=qP?LbeM~}fdE=i zV9GHsfP0^5?MN>y^!)DRJ)L5Z@#am%_wPFZCaE9pZm&zhgYD`vQl?Fvb9Qkd$`CdJ zxYX~G5gqR`CfW_!J*WpK<`4KCF0HFOxVyT)C~X!B4*@1yTITzsZC$6ne@tnAtft$z z?I)!#ks^(=>o%HtdJ;we+!IT3UF=@ErbnR>KOn)G*aHOl{ewh3~PKkeLZd!0Y+sdQW&`77UGlKst0 zc7YD(!q6zzRq77@rHNS#D*J_qqb;xks$gZi-7K1$^AbsX>kIr&?S>%*tmt$;a^De_X0wLqu(qoRYg&S z^k^lYho)$1ZyyF$yLIip_1yv9{?p7iOTxY+qz)~dtGYQA&8>knJOJAf(6p9Q_${B8)9b znsJE`WT+F=w(lO^KKovzFt~N73gd$7{x&zl%44@v$6vC#uocWPB9FP+rd*P!eD`i8 z;WqF|>WT`7G6xilf$~F=BGh9W8>kSpCvLX3)Obl66DkR_gM`KhQCSB?jq;57@qQ>@ z5?kW@&I%BMcYW0nKd4ld&yg|_)&HW4xlTj80C_OT@ZdKEe}b3>gh)vJXmtb>n-4l7 zR{HfTZ?o0IH*fX|Qa;+#Ho+FaEW&;_tBW-?H@^b}RZL&7gjyH($QpZxa3n%i=#gL& z^Yc&eU14R|VEK0G@b)m+RW5E=G^_A&!CeX=&>O8imGS^iLFfev7 zNvK7$!oL{J+Q8d!n>`v_qcY6pPMtfq4kOH5Zb5gkT~32AL#|&3P=s1TJC>2wkO7er zvQ00T7k$BNzAu9CQf4YFLlvrMsk8TdsZ=j7FWvkbs~VlKzWpg_#bVJlYeOj(R#sad zocjITxykp@wn2rp?SZ1?9!1b8f-6Ktx$WMWAVe8YDqHx?w^3z71dYdwX=OC)wD^rn z;#)R{Z(`=Pp3Ah#vfRI3`gy(~xs!)cik!)Y#)PkO&lNZyEf=4Dc=FY>wSser98!QY zg1)brzlg}}Iux(fNvAF97B~kdBy5AoWNjUMDxq6j1tK~0gneEMa!`ArPC#10_QdEX zzxd{{E;=g8==$}+v239^QI17Q4gAJ+>(;?oT@d+ZNWPwtkl+Ue8_40(rAuRKM=5L6Jj-7#K<;U&98vU^{cVp}xK( zaNYJ?yRPMckO3iSXnU#pMeidFNL66%5MJ0WuIKOj#_FQNhd6t}$ zosG2s0_AFS+9)8fCFacH3e$F3eTtX(gp%8k@6uQu_w}?MHlN0EF`AybsLiI&I;IGFIkxdP8sIFRX0daOxA$r^ON>2C?33rtg?Icu_uMP2<{FL12Fl*r!1S!F z9&Zuv^ermG0lt_7*vbRL%~>C6YZo?`=IBZD|Ml#dFG$5F;6AaivCsr7dBR8|$QUT| z%U7@9S(uxfKagf<;1la& zB_y9WwzvPN5&`yWVQp<{WYiU<>e}dpodVi)-O`ev#0?D%*wz3u+%ZY*!)x%;xFZmC zn&3U_7EN7lVeTKS%a^SUWC7R$H-R=26&E)f#0q5y+S2PcZ_us?r03)`9Vm9wcf|#^ zh^`m~&FGVzojoD$qdh_tZpi_iMi**Es1)P5lSk|1%c0*xYp!J-@SM8`mc>x;JA4xj z0esQrTCA`^W0G6!=uq9Gj4qwUZk?g=!kBh$-VUMZRx`f^vSmxCd+ns4!Ex)lQbr6! zT(F2bZ~7jn9C>*dkNX_r4q6Jv4(Rvjw2U2Sp_&e%VMAFXY@VyKfkymI*%}#_t^$?T z)Mx;rHnq1WYV9~+Z*wh62jh;WJNoIaD>G?`>>s zWYJ%sj8|Q~4{yri(bUY00}R9BrHRwR2i@O-NfHA->;y~!Un713PKQ!5B!X~DP<58N zeU6BMgGfk7q^GAJQi_?eeVp`@62iNB2%#JSm!jGBE$~u;kD;?+(i-dV4%EdBybWCU z);L4t;Dz9v5jO9Tg|@K8DuO`M-J7yU>g?=#Dmj7Y;gQ2fM{5xAn)B2M_tV;Wao?jeIga^15*yKvV&X>JPo27Kr(oB5k%3*!Qp;e z8{u#uL2@dAYj4QpCe`+0uV~N-m_*);#SQ{iKr#ta@nr@$1*hM zFJZJ-&6lTlte+_8&%w5;xrVhr&dV>Rz?93_6>91oIDw zZf3?AkgK`1^{R%Eyr!EVomBjA4 z)ma{Cv^kTwH|iAorPN-{E84aU*F9yfEmxAyBlII7C6$$(?QG5w>vTrsO=^3-8-qUQ zDkx}6Wl#_HKQDY35x$rzO6$S?)X?S*`BB=_{k)NzWjTv)>Go^&^Qn{7SNwD83>od6 z`)v{AziFb|8xvPLNpGldP=p z*>;c69!hA#moI%TU%-2T5KvUO^Ir5{LMVWPjFu7K2>$|g2GQa>m6>9X$)tHVBIgxb zC^vPy&V%m8=pWf{`++MYI!CjRB6uLy`}+qbHcE?!2364eV(NIO@{DIAY2qtw{3$Q> zk?Uu96_UEg&~zasywIJhe$PPvvClF+)o}JMCcZN9HRYnWgG~8zx!!7+zu|CS&Zc+=UPsi(i$>%3C3YUZkB*AInUi;YXyn85wIR_E=LU406JRLtlJw3T7U}W0X>DEf-qdRoN)Dq3#voXh5hm{4N+3tyzuO zMg|7kqiYC~Yl<&-=SxDo#5Yng_2H4q1ZN!j(h% zgh&=bKizX3YZntEgsz}-bDUsPMkvjA*>MR_3PJ*}j+c#r3w`PXd z&Ic2-wm)qLLf}G0Llt(&TWXyZYIplmU*AbhdGu%#ejRcIN(f}B!gyROJ{SSGpYk89 zbNakli~NrpkFc|8g*|Kh)BzUZ6{t5LFL$r%syReZOgr^^1|r*)4pe z&-T5FT#zC_SU~wyH8r`>7BEyGP*BMXWf1cJ%O`*rbQ}8!MMC4;0b%6JYP^|4k3GZ9 zA_#;Ih#Hoak%3451lk}Z@eT&v1zi?l4cszALlp2!GJ>WM2&dtyD7_%@y4z(6O+@w> za-2IKfc~`>x`jh)2gZQm6TV#&E#;jDwI~39?dw;d%){2!BYPN!2(=8*2WlB15PvYq z;0Hv2pb54JAytJjUsorw`tL`m!Wu(|^69fB+s{1i1ZKm_3A^9zQA7`2yOO1;wUx-# zq12G!0)Pm(wFK%wmzYhoa6k?Tbp|$#FJhM17SFRHt=i{soK>fTRlg(^AQ`JdHc9=d z0;^c{uNW>IyM{esP`>N0$k6aw>*>v?9r+vwY$|C4`Fl$GrH8PIGH-p;F5%y+1> zF~7tPCA8d{YtWrcjE!#@O20$24%H6xui@LbK|UKnC$EW+IG`|WEkrQl!JxqY$2v=y zUwnBHcWz>}HG;^e4+xvVJ>Ty#$`e$kc2TQu?Xng zghq_5_A9JK$Q>vLEaB-0$u+C}a*@q~YJjGQTc{WtlSVyZ?~xl^L9U?NG&k!AA8;)} zZW`O^kZ1`oBf**N?CwD@+q!l<=vtxABz%)uYKNJPO$hD?nct-kUxNh{kf09fpwSI| z3czX+TDpx^g?2YLQ7uI)iy}$}#1OqE?8I$5#bK#E;ZcBV_R%5T|vjbYK!fK!-k$ z03nf~z>ZONe8?xoLx&UE;KPQ z5t;{LCzakEGqC~)M|}Toc=hUSNcXTDP%a&WQ4S}L=#^NUrOFKb@eiPUjz3Q&+^ZWP z1hOb~Y!}!E$h{z>l|!bzGGLkPi!i`fmkO^SZGaMbtmH&QV9-m!Oki@L(*ba|7I{P< zxsZ{czwlOKEnH(FoC#Bl;q^IMhA_jx3Bbe%1;^T2Xr~z71W!rQp!frf19MAD6Ct_w1!}8XFqQ>+5-#nVHf1z%)HQTe+8xi>-lFw^Z2CR^XBcRT$=pR?Zy! z5WLP^Qys8svLOU}0Pad0z;H!c3-?TSeD-k%z=3X_gWoTmIMD)II&McQQkq=s?CiJ) z;^70I;j&SZ5NI&Q=^vI)PEKmBA30FF>oTs{)XuKHZm;;-RbWu?MLm`401j|FL^vNl zTk?4^evY#S26eb-$kL?oTaCYwF-FtVOF)Hky&1}=0n+Ahmzw!6X{4hPyjyUc`T3_@ zTyn@YsY98#WE8gxwS5}|7>Iu;e_Rb+rU=m`6i|*$+l0VrvUxiPQ@dBstZ9op0vUVQ z#zI);i-KIS75O=ec}12Na(n)Ay^jgqxq#%L1)(CmcRU6O`C!8f> zVF;?ASaI?v_!#$v1iql7jPfs~zug2Rz&h&|xj!(JzGJFW2&O_8cx~|rB+q+6$o9H` zR6SM0ulK?kW$Tur=&rNLx2;=%gS`KUFcaSX<%Nn3j)+J`K!X7e0v?`+w|WL8iYWo$ zfjLD$9DWax^$5yhlgGXPKAr*oLjE z!i3&GUO;xol!IK0E5HC&gy6%NS7Df)P5z864F5o)x_h*(0Qn56H^YOFed?dj#(2HC z495cC4~H`fMXq{B=cWc(Jc1&qGW^4DtmtXL;w*KKiVD@`#%|sqsDN{u9jP6>e0=k0 zU8`7TdQRvo{D_Z7Wc4<~_M`b{s=u|)SIuv~&0J7w+Ba{QJ1Qy_>Gmt72y}Z%!oNq6 zH0KKT54jzw%SlZrSR~Th7Pfe;chretKU#Zs&$J$zF~MaD3Zj9$V2D75BC>8;JKm!C zVibW&eQIv5LT|*tEBP3#kn&|`b#*oP4UybJHzDR_wG??PDmW)6U??B6>tjpHkVp_P ze=FIW@vi%~v~x~?4Df~GJ8l*wCbA-FxPCQw5oFQM0T*CjJiAvHk@*-ak3(x?g1SGE z##l&0t_ipsSP+LE_#7}I#xaqgk>E;5H%kbs38Vw|`sAJq*n}uK30`JHWbI&`NJgN| zfUHyyx%-h3B%}#ls;Now#=(D??*$#S5HuYmU!X)LT#}HLmM*dNUc3V1oJi9=+!%Ze z>Q7bGNyP3E-9e?`lo!5&C-5)(^8;KasBAcxrkI+lj0D)jMC zGykq4Q3g>406@@H(3XIAh)19d{q~Fm@3XVXn43i7L+%1ctbjj(hQPx?01(K8Ed#Mv zq?b`HLNA$^ki{F~?2J%8*D|`LKAITe>=zb(z(Im@p|dc#c0MphqbE)j5v>_(0*DQj zT}$+UAlDvQ*;_;T$h|TxW+}Rw&@uOjo9lCcS3JM$2UIM83GGzfE?;hCLkuS<32bd! z!d3#iCMW*}vI1T=<=chJ8y&SFoLh81To0_ToevPUBESPODBxsviXVujg!hP_1t6-7 zb1D@e6Gsb(aNN&~@ze7=QF<6xFiRXAvkcx{kl>|_&LYZ{Yg${sBBwqyHF_kDQLTRF%+F*ts3hbA0DpvM z;j@KnD#wFh!Z2ZYNrO2rw%H3_zH%k%(Ic_Ax=AU#bv>VoCp#D9naGZfwZ&ecON?vT2DjWZTBn(*MT|+ z@U-FYLB{-*>-HcorT_Z}pqC#vzNu(;TtGZZh4=u5Pi4ShpkyS`xeNQ69xS1?L905q z^Fe!L^uUXu%pXb;7lnAOBQ`f|^ReHtUqmLpLt2q9^SPq5fxUV9lL}dLX-@GJ$to(Rg$vDd8kU;b@dT@re&R|iM+ZWe%fhcWN*U#hH zsJiQ4k<=wum!OmH+0}pu0V=qn0*cv6R4=}T77WJ(%rYx4Z&eB2dAnvT(AL6(WQaxMg&|4Rzui4K<1ds=%c`SNbZ9~o!Gh-omGVbqy*rFu!l6x zLgClJx&Z=lIf_n$>!Abzi6dfIfxb;-CEzF%T@XH}ySqCM@RryMlIO1z%}OEzZ;$y1 zyhxW|%hRo;J}<)YR|Fryz0ezhKmo4n;_pUAJh(-Zq*h&B+vo&qy{iLfU~!zv5Z*V~ z7GjBz7wOY;Zf-GehXx0iI9d>pM%m*f2*L%H%b2lcIPV}ULZ89`Iyk9B@)g^4dz@!k zcefZg6nKS@JI(A>W2t44#Uss{>+X%O2oe#qpTbyV(8)w7yXM;b(q!E{^0P?IgppMW zZAPIs;1VYz$V+}`XsC5PJw0Rv0`b=9>UfU8QAD1Z!x!+LK;~rn5E()ArP^8Oh>%1P zEJUL*baf>F7N8waeSW^Xc|l<_AA0H>-j6VN;qZY7V7mW+5GKdk?u^tER+FHx#D6%! z2g9nKS{QdKff$@H@;t{%*>D~lo(ryZ&5s+K8ss)2QEsNPxG|2j@LRU^9so^v6zJ37 z=nF9mQiwJ|fB}yKI)LzlONKy5tRLh=BBeglY(_+DAnb#JLfFTY%(ygVc9}R&Odyv{ z!44=bqJVyd?a+mUHbp-RVN1J1a3IJ<&0H8|IK%@F4@7(}!h4?2TZAzl>g_4^d89^h zkFs)Yc>p(LbRw>XChWK{WkEcE@g54I41jWW(}Ndqb^yZgV`Z;L08T_435^d+36*Pc z&o)6PU2%kzK!&NL+5Lpbf(Q`Nh2b}4WvSu9aqJJz2D&HVQ&?HuJseNC|8oQzMN9@^ z$-BY9h>EKM`5+d9cpL5>{!F^3Vnw*Q4x>X$EH2)|@Tb`@HD~Q1zrR zWHUsgYbGlA4Cb<&i7UiC>`_#FN1PS{vB55*ORST+WvT-amxw8Yq4t>VkBfjjA|>UB zd$U12y1Tnu;0p)ss0Tdtb~yp?9?z+d$I*g{CV0~LQfsfu_B!OLHJB6*IxW^ZnW>zJ zbJFckk>q~CHS+Li{qpKIModq>VkL=0xRj+Q z8*=)`zB;vacOJ2FJ}f;{-&^#gBJYXnGb0}57XsvEr{&cz3iYRO%@62?XzLZHr+0Rg zxbX#{hr?@vI;eTWZB|)J43`A{vcDXbMXmzgJ zq&NmQ=>nSgwVyUz+m-@OOgifpKn&v+c>{0SUf_hG3|NYi5zj8aBG3(>3~~xcv3`yF z`o85M&=q(L)L}+RK92UlWr(+jh6RA={gBWJ$)Bq21-+X=K1muOc3qmuXipD}a7;27otShqQATNpN6+P*4}P5n zVw%%?y)N)}$H<~#?&R~nqTs^ie^Re24GP)nk4+7JN%5TiqK&NFFP|Y1G~!&+nR%lN zQpd;VhFnc{*$St%+<-HJ|d#q(qe*q z7jV6b;S!_@2gop1Es4;bkO)}Vm?WTJGZyN-Na=HunfweMji1{*+IoD4G4{ek0jWi&`&nm#a#{TM?Sco_P)CO8+keB#6sJ|=;{ zl1Nk$l4|#@*$(eGoWMf&3jib3Yb3{73My1))|tcj}bq3(|hjE9zS_J&Tp8SR&YO6 zke8P|a6lb1kNCg|^QGrQHLCiCi%4S;2i5SP=Cibk-U#~rGu?#~rXo4|<;K)$431s9 zb`iUT5XEq}0IOs!thPAMN~9~Qv<SN`@gPb{d^*;D8+y+Wa|mxg_(Z`Gq3JAx^@}ep{c?s6$9Iy+t}D({GhM{qi2%C( zk$}xDydXOM0`P5OzR#!?z(A%UQajB#t zTepm#g{d3|@t^EA{A9clKZ!&5SGfMfA+{CKv=>Z#(syagZ}}MU;InyNv#7J);^k9g zr7-ip;=MF>b>rVy;@;Z1QwxR9bmguRzuqyb62!>BsN!$#u~EurZA@=03dND^*gACk1Q@k%$}!Cl1&M z!nlFk2rXmzjM}7r-*8VF89&nAj>8)5(>djfPxK{t${@b z{4Tquy5)jXb0qiZgH&gCP7x7Y8_ES$l`q0If)k?M6+y(wHUI#`!R9`2F(&%QGfmK% zfGLSYmIN zC`cA;MVO};o9K-YT8;QwAwD?d4V&FUT=yMx(L@X3LGzKma8NOjaYi>E3mzxAWge~@ zJhfkLG7f%`#t)&vf*de?`0!!QNEe^zS<7g5^{BofNSqXgR7DU6_{G2Mr1;q_X)-?` zR}-iDT}E5SeJc>SHW^pLQ4(+fB74x7s%PTj!dEVhJPST@;UVl7oW_71FfcRH0!{M^ z^|SROad?%O*S}(I>i`o}1}{&}ITnrJ;7%`ECh<`OgyL1*NA!E*%`g%@o-%!~7esBM zuKLhw0%Ri~BEiZf7kERXhVM(iy9Y}RD~Fj-U}1pZ2=3Ff$xLw9poeXFaLOKF6cXeQ z!0XCjfdE-2tQs)+I0XG*3@m{4CF_Yy_fQ<>!Bgbh2-Zr#xsjHfICF_%r&?QloDD9I z{&E?_8KN$r;NqyvjT?Vn$*}=)=?twyRuKS&Ou_>vK)5$~7KdpO5W=DnnX2%r`i_n| z0!$(?glz4>8xjKkrNpOXz@vkU8rMwZO`+m|f08E^r$#qml+WX{eh8b0uw)=_Er@Xf4DufC-7srE(?e#8&9Bf00GfFjg?Ppxu>_oMnYWC+UmS z@Dp2+W1tVKv4v_-i@rr)=)ni|K;=ix&XF0j3vXG`tV7Zx>4n66}3yXKwBF->1*w<=0n z&wgcYy}8oV6;^RcRgS}~mr`*mc%{+3^e4;0Z~ByuKPi7bB7MPLCsoPno1_lq$@O9C zmIj}dT4s;XVBMMPMPBo-d?Y8pv!}%Ul$;7x>A=hUR>}S97p-2@kpqjN-{t8lPpHuv zTotZ_2Grc#PB-iM37^cmOF~$=SCPh3wyZ9(ZdO@La@)Cw%XH zKJEU^t=Eqx5_hn3d_bsB#>;b)q-~KyUTb`xN?j(qA8`<=UgUc0wBX;)9;|UI65=2U z_>UiP{SiZ~Wx~}s)&f_@F^YXaV*tF;VGfHjBXE?T#PySY_ zuK#{^>pC$B>R323q2**Bd@I&mQlf z*vqRY9-%=?xf??p%(rPNx{v;_D}N@uF*_d?;iA=&md#%e6E>$SI4-qLSn?JMQdCBF zm9b-|pT<0Ly%#71b#KZrty{w?(iO{ogtmbc-R$=cMrrMBZ?VTH*-%;+Jx_Ca#6HpgfZb7Y^iA_E zw@@PAy}OX)o=~Qtib@e~@@4t0-LvPO7fxG?Zgbd066boP=Bn5>5 z9}rU_HAKJ3G^n<0E*oqC+E3aX``dX4+uJm(qSp%I0G?K=_Yrg3EyIwyi}K{(^}v>Bd5o+JxEJ6MhM~i) ze$NG<4v(3V0_BJ%IX*I0Ijmd6XyR+I`JcrR@K3@d2#mIc0pRKK7{3MRxWp@eVKH;9 zbKP|)4aAJ7Wmg!)F^;A&oZ3UBf4Y1yR>A;w#)U=7kq;QE{)stJ3SXi?<;+nl{~F)*P|X?9!~8gx$(TRuUp2}SJ98vTLaq{ z7r7bt9;PxNb*#m_z%EPw(e;#Em^#w(P~D#;8L=v_W^S1LcFAdlY0qUwlx<&Qs8gZt z{MxHI^kUTmH`L`h#n&g-tCY6R>G0Z}Wt1`7#IfG>Y-h+LqyJ6nnrW*#1_3fJ zT=<*bxfNcUQm622$+v7(T)8Nrb~t~NTECX|ttI>5mxnVjoIf9(Lh!vA-)@bkq=$5F z&2Snq`-C1D*T%-5Sv*)5P$b|qFRv6-86m5+B>5+A0TC%9Yy^8SWz1mfHuXhnVc|t{`P!LZZ|O?KG}Lwd6VllZsz}s-2?+|X)Mneg?=FVLB_tFm z<%iyqzq^b6thWUP4FNh&15$g(I2XJ;^NrmnhprLK0i`R|B6TH`IO@736WC#GS}Lj~ zU*Ns~9|;N?*C9RNZST9OzF5C%RcWMuo?{Hp1j1Jgim0rF)1RiU{@LL-B^o8Lz~DOp zR6>Qi)#o57(qAEz72XLbY;A4bZ^oR42d|)Sbo`UNC~@e%TfWk+Eo9U$pe@&6J1D#dbXzA$K^di>jwhVP< zw4wkjL<&7MWFIO()B0Rs*N+I66} zmXTH6^>){sx}Hs=I?+IHy}hn80z=Sj*$<76I_i$5nORuS4wc$&rr5YC*gx;_@Wi<0 z{d_JOuvV>NKMIR>$If5;hmLb@oML9T3fRS55ah8>pZ?LQc%0JcMmyKR4*np&j7m)= z6ec=5Z- z^w53v_e`sS(?>Fw;zmlzjzfzRwyxZ+>mae{#^T%~6IZDB{~*&v_tnUrhx?QUm9&rf z+Hj)FRw*ji(5+sPi`Jakb@o8WlhYIIU9Kh%6Z&*@ynH`j};chWn4|Ckiv zuJwNa&QK@acA|K7VZvg(Vwz>nNs-3k?3cRF*<(;z87ctPgGSs>vU%4J=RbrQ)FBd? zpgu5swT#kf+OA$ni4UQj5Vce;X!}lubPJ*g4$PlMuasNLNn<(z<^3`@2r@!g=Itxa zDE(O<+szzt8B>~rt=oZrx~c9=#GXReL)@pt2w1K38H_QcPh#u3<=PkHmwhyw^@-Dh zhKO)P&2{j~%q_~N7m*A|fR{&hj(9w!4=0%4blZMFwfnyquyuNT_xHfJ1(`}NWors2 znFtHSuj#$FK=5(!$e??%*Wq~+SfzhYtkz<@ns9yL+6+q%8>+s0#}3hR#y(!PcCA#g zL+={;Qc!6*utZ8K7Mh`dJ1?w%$g9*N!7F;EPGRA8M`Zs~vI*KO*ZO~>R@PXyn%%k|>Ak^L02r1ZY~tCrO@blhnFw9)2=yXG8J(NJl5`(+9j zetjBu(MfW;S;~{rtU90GI9%Pe`_gP6yIgzMvtrxO(z0FG+g43Dy$G$UwQMzw8A(Y+ zI$81dIN;&Z$d}Epf;wnfxu@fKG`<^#xPeWS+?a*{IBm>?xW5cp9Ijj_qQ7|*KCF%E z{bzw^XK(2f8D0Cit9JQE_ub*xOCHU;hIag7R+_)VM^e9Uw>-(bDGN*s(;g+;_I=ao zzPu{A=2uNq@gpPfywAD z?B^Bq`QJ7*oHK&+C`-p%HYRljlJJEZ zMh7Hnm>^|QKZIbQCifAit(dX|<`z`3AVN(|AK1ww*R)ma!0Cq-30)$QW^J9T@0b?B zEQF$Q?<%hIU`2-{&quJO7(s5w#bs;{a(%=GX2p^CnDm2Ak0AHmyZ8Vg8Jj4zwBQdE z6$R4`%)o7dv6J1gyC60V5X<}==_BeiA`1@;QY0T!7&Cb!l&>5GN_QIDiy6o>cA)oK zSI2~XS*yAWeBI~;HKDTMUNVxbFz5OE4^0WMqX&-6{O0Z35~dax6Nl#?y)JX32sY~6 zl1Hi`Eu-Q~pH)~VA$zlCofYowsyZV(?=*bx`ci`82Sy*0Kk`!>x7N@k)%@7*>!J62 zH}v|aA8&PDth9fP^V98~&X0*V5Ciyk=<=aZeSofAeRu=(L8}ZJ^bU>9&&$vk$+>$Qh_iB0T0%4ZAG_Z zLDzbBzPjtad-wAsbFnv`G}o8W(?BH(U&=ld-Q7ZT5c4FM(Y*3?)zUhKL;)D6KZOYU zgfmu}nJ`;1RGv(U4b$9DblzGrPTzsJd! z2ie$eX>9jQ+SBLF&5hf3dD$#diOEn8BVdw z1L$x_HJeZY8!iNh=H_NS1@oer95v1Ywo-Lwtl$!{Rd36wsfbs(b9a|cc9*xUv#-_T zESiZ%MXTr^J7>1OK2er3_34&r2wC+oWIqOf)P@!OjUnVejQ%DXg7JuW6|j$LbLxPP zC^xpuizZbUJym|0uQJe^5wTScQ9-n4qCCuzbvzJ1#+^-xwFO!5#p~CG40|R8ux4}Y zXPfQbg_9bu1eAv1Pg^#jNd&?v<*yu%tig1HSI-W`gk;M;6KjX*wx!@}Yild<6(6zS z0h!rBMo4xms}nl7>lGu69G)j( zsWo?N9tUIT*546*jEWMo#l^qEh={^y!9sxj#0)CT!3>Fn+WYF+0rlW!cbudh9i9E| z9UgpPgBH$EKEAsql_zabz(D%s%^jE3ZJOJ-)xO~SnU6;<=mqzuOHuS;Wp0bwm2dt_ zWPZo18@fMwQbZ@D;L~$Uh)CK1Nl`z*C=VU@sd1Zupq5RPnL@=z|Jf>Er@wZujS?6e z0S80q{8@es6LdS3h-8_;Z^u|4Hzs98vNqLnI=MX^IYIcmXvNGkalB2!WV|s1Z4|P|$dt0Yy{559Q_8 ziY|A?IVw~qtY}Sf2_q2E5lTM`>;Xqs)F;8|%d6^KhS0ktE6s<1O=y3AjVd4J5RkEa z=h@%RyVY4?Pccz$$*YHGN9YZ@x7?cE0_q(4eX=@;RE2N072hm6$Bqh&Ol*?$A)<>-BdXQTh*dj;GA+7w8!IuftNCy77BAJif1TYHNUY z`nVd?$|D1UkoH0$rk)EW98ocvQ~HE2H)^Aip-$0wl2(ph`OJk|gCUyUMzzsC*~^HGG;@$Z#k zdPxrQUQGFg5I%;n z3SVICPZ##HXMGp}e9q~x=cvu;c47)Nbr5 z{uWnNgEIDHi|C4?(*vf9g5tho-DnIEoGQi(@m!$2Ft9@HkvF0CXj1>)+dKZn-mujg z`&_?D-vu@EJn*edVY&{d9XB5b*$#OXQXgO56{yvU^2te5-h@yh0GT1V!(|OA8nX~d zd7Onpb9PzMM-J- z(EN=XZe>%;`~tSF6C^U43Qvsj=J|ICg1iJfMtIlI=b+St$>rl79qaLj%Le!JLNoY9 zRvJ@0%;Mh&>xqfa*>eAx`Nte3;YgsuPO7HtPilnWVZcNbe~3>+5GN>4rx6q8n6eml z1D;0C4-Nd;ix-4Xyv*U0Tdb=dm zR8Vkti~L)<$|>*5uB{RVLx)`B^Jm2G+B4D0Zq$~SgQhGzR~=m4mKC@5csGBCzK_+X zthK+t{>Im(Z@1p6{M}Ar=h+~whn-sKuQD$D^TGc6#?1{I3#t>!I{&)SBo$ZT5^0xH z;h5AoXF@K`m_54-wNzRXkFfioZ?XRQai0z3C)sqh(F`h+`=(WDXZy~q;V&omeS~QO zsF=v7s}hi@c+Gb3e-+?=#1y5>-S)Gorl{)QNG}a@&LA9LO+FTSa*|iH~ z#LwPJ){BJ{ZCtGuoi5QtdZ1UGW1Dx)sG^x>Ud1FYU?wVVNN{k;<89~229%66*PpN$ zhPq4bC@3gdY3%`@8oPpKO_~8-$M;0tE7gB$gR!sbj zwx7Z(Nw+enlWvS6r-=F!>AVo8x6kF}f@g-$7um(u9e9mn5HLkAgUq9df z*HDO=#o!R6ZS4O+`~`Z7sIRe}AY0H5#<{|gL84`qKpr4)?0Re{JhuD_MrOP-2#E+3 zZ07m9GuZG@4L@N1zI~g{k4HwkbLWGXkIYP&=O6blM~>Bi;UjRjNLq4KMXwQ{hB(O( zx%nqP%4BXs1rX7fA;~b)TTw5VATww0kfTE{Gs$_`6%iLOdWr!}%^wDd89Kmc)(3MC zcAkf|wY7hwZIar0dmUs<#Vo51na&a&TdW>Go5ykBw@41u`IC{1_Z(5JiP6IU$<=m4 zd_@DjLqg;8AlL=8zT0|b#r`FO2MIff2#SoK=&1G5VbH{VY#n5Be3h^kRAj=n`D9BI zj!kisKwNVXj}jy54MwI5(v@8Y(h;S8**kiS*nu2Tp)EvVL&a={l?{3hy9$gTrL))` z;5hW7Qigst#9hhD$&tW`%l!KH)H$33D%Pf!ibyyzdi+Z7pDyb@$3HEr*KxX9@MF&Ey*(eLVRDckz6ysG-X`*pfZ0jSnKr0Z zb&XkgBmr79u)N66&!G4s7K!~V=JEc=FN{wcj!{_oD*?T{vnydsx%p{Su8QX4)P9LSYI7}@WU$zI{75^3} zpds7=>sOPg$th0;*{Dc$UaBAcDD##w)C?gz=mf1!+@S&!3!maXq3$0!m!bYqo(vrN zF@&VO^vkXb(_X}N98N8%xnTUHHFX)c;*>j*zuC8hDqQqL=*5$aoh~#=8P%ljTfUs3 ziMwcIp-FoB)TwnZ^ZwT$0<(zpxLHcM=ic=`tk^cYJc`Q_)}85W^(?xtt*Fv|qo%ZQ zyxE&t#7n-$N(kk(Yx9C{JS}vnVVT>$o=y6|@Nsr22b@jmX4O#KSVmcmDkQ25VFp0a zo|KgCD#!f2vU+m!R}6eDOdJ$*S2|4AoiX5O2-82;52&h$;g(?J`HJpl2s{8oM^O7qY7gH&UaW_OUF`tj<7g?r$Lv3l(@-s0LdS5C|2$lS- z?_=%so3pcn<-6*gSvwyVR_a7I4vHF(5H~$;s`-fM*l~9k92?TT*U8$uD+*l#)|@Un zqLC#ra_*sl1}k6I`);};clD{pIj3pi5?VDbFTdQGV2Hq%N_~#@PNxl=T**EShGBYQ zVkY)@-)ND_nm;pO@W$r(hKBlHRgl8ejnys(LbH=G(vy$GAI9OAQerDCn7?lwzCTW3 zPLK?KIV&3*oo{byT$7xQ_gY2=G#93?QyzkMr36E$?fnfE|t#Zo51xVL|Lx1cO9<8b@W@ozQf7#NMrmQgJ=d(EDYiQoh{*Z%;JdL#FDe>n^_D%- z-8!$G%%vW-`; ze#+CrD5#74%6g+l8e~q6eSGUU9|t0hYn~f4z&LArJNM|(Y#ti4^7GwZBVSpMQtcLe z^l;Tbhvozxwg~JoC!+gV<8E<=hx$$)HcI!GUt2Y5Vq_)PNKW{5;3BJ0Z&&vJ?ZBX7 zna9$WeI=bPOE#?Roa2AZEYj=AmP7efN3+WGfEBR)4`o(lZX5J*&pPEAS#N#7PMTMC z&`X&mC&%hud6n{I--C*V2W9na%ws;u&Iu|HK7)wq6bM@_ePo`6d6&a286Iq0+Sv8F z`|XJSH_Ee3qIiFt9kCT}+wdrpK6n~YT=f#nhT6!QMhs!)F-$&U#Qi~df7bhs>10*} z0%p^1zxCpk`6%&U!h&13-G&jW3oLZZ`TcHw)sXtd8(%-MQVV&z6UWsh6J>@oavu!- zdrEv))r>s}m9wK7r4rB1HnzXfbYKoXX`hPa8>dyJ&#PJ;XztsNJj=Z| z+Dj_)Q-dc+bDS)u3=^0LJ-VjV#U(s<-u(@aQ1jVUQ)pQOU${*Rmv8GIR<^9Y)m3?j zo<^V{pFP{^Fe{zXhBc5pwK?Jji_HaYe$<{U!{-L)H&y#-fdn9P!uU`A#jYT1vyom7 zi|uFFjn0|~;~hoqtIuo&m*eE(pE(uw6MRPBntXEXsK8~L_wF53Vr#Nxz;VMd&$dm6 zFaf_Zs^iGYusb%b!WYIXlBRw{{ zq1l`E?UO~5r^A`a_=A_O3&=P736BGXjA?gnf=)FQkTdC zC~SfgKa`P{yb3m4%jhl%W-{x}Fqbc_&S=EGi9VyBFTTIQFv>(rtu@Cw8mlj)J-=&U zL}Wa|L4P~x_S-)E9X~@)=Y$i;S_{UGHme`mFqLVJY*9-7FsEz#3uTVXLr@CI{w*|n zu9{pL&G99B?%U!4L?ZKjUV2M8lvo^;#cW<=vQ>xg=DJ>6J&~H0u{Q*Q)~_bFl=VWh zi-Cne{s(|d;5AiJ&(K)`OL-b`i^j1S;Gn5%bN6yPgZ_*5n-6@qZ*0A#JVw6X=H0uO$%{H1oPQ}UZvB=@RtJEE z&ykxK@^-R&M)psppb5^auwzr9W?1ap*$0%rLRL~Eupd9(`tuH#3^vfc{Z@ZYkeJ6n zToZ?eaLTg5&Crmk^Ix+s$+#}|iYU*gzuW{3C^=Lq3; zu5i*sa~3Z9=3U||p)uswn}(sfyaA*=FvT109SB2o_`{zmnKXe?qP*`2S*gS*R`M*< z#TqmFEcEi$i2k@S?V5h z=U%^nwuk=OD{z^VXUah?1cH}NCb~6mQIq>)*_EifcaPGWaBT0T=bP=_a}JH3u|2vm zaYKYLvfZYc0XRl4#wzB#$G%A?f%2YBkv=VE4DWCB^FHzYRIjf|-a4xjo>dpvXxPiF zmtL__cIVTjl}`s&?>KL};NaSDT`%K9>nEj*UpS@9i{HjxYFO&fe)R8=MJIpX={o5B z@)?7-$Voqy*fL?o@|s51vALIOvCNAJ3m|s8)HOCAl4s5O!e*pvpZ0aJu*!WMZjP=> zU(|kZQ%t@v_i$g<08$&<=}>A(|HT7eThHKelP-n7ocIdT!gRosWN~@s_d>%r8%jtV zlvt>*yPCPa9bbYvdiC?_MqU-hScXjY9{dn`LO9zWh|&Pq@?OThsT*u3{th%^gCjLa zQyndlopM}FaF~7*r5?H0N<2?nvvG{W#i%g0Iv_LTWrKnLxfWe!0{5g>Dt*08!VySB z^6@agN3J$c2{3;w=q}!v@sOB|dnOgRf0nA7Bh|f0hVU^+M_u0V%vM5iQ@fj%)7S1XHbswK zzC3B-H!nOFYsRporZiP94e>#29q7;HY~^0jHS;25bFHJV=2c`VCr6nmLk*_jL}e7<90F=FS^O z|Jl0EkV#fz^fqFd&y4~J_9H_|4pY((u%2;l34p5g!9$)SbizH_q1+fZ$dE+NUB_7e z3Ymo*UzM}s72Di&;K&In`!p0#N4UPl4*&(HHYP4FJSP$&o-l*UR^>}Q1_pUlz+qioSDfslFE^mtdH13;>tsfReg5VjpdeN4?YI1!k%K+e9 zCTa^INhvgSIr9uCsOUlIkyPXl5V$h$-ZjHe59l;94)19JM1<+E)d-xz=WepFw;v38 zAW8OLIuj$5sNeDK7BlilZ1A@`kA_UBZEc5L!VO0>yqarGA>I0*{^Z!I-pS#8)mYBs zoYM0LsjiNahXSxyuMMhPsvt-3t^hN0mX&?Vq`2uqmFM)4x85iP2WrQ{0;b2+jS z*`+biVTMVqw{DaNJZWfS(ilv18tofBb;2e40Ih}38>j>; zZLaS1_mDII+OfeCbNVOdR~eG-qD*8B`ofeGO>Z**&D;$1*9ZZlgVGEu#)B`;~nk-0AQ8_MI~2Gsu>uy|#WZ%)0sre<#m zR(vjLKW#cB87m4DPwNA2k5`@BzsbwPNk z)~t>;<-W{53{f}?_6|Q(8TcyNbm=oqDq9N=V({qw``&x*D_6UU{ef$O zO`Ba*Bomp}J0SDuyx@cQZLt?1*Nv9%=iAjEj80|By;mg&>aDjflV$m)GdT8f#y6N19CbxmQZX_j; z&b9T0hn4P7C{Y8@A+uMMJoY)%Bk{`{nFF1p+!D-GWA(8XNka3?o$sRgY)J1d>i|9P8uk9n+?Gn% zrk>-GVzfUBvBphwrSm&ePlZe%kcd(+-zLe=p~!V8rkk5@cfMbG*DO-+$}8Q-yi^j< z-rWxqzV68W2f*y%~nW8wr3JBS53oN9O3*xX>vTwquu)xR5FKT%^ z6go)(er}p5bMzf?DB*#-It(0H>zu%LrkzD}II<7vJNLpOXH?Sk&k9+C)hCzH#=QCeFG?XjRasW|Zr>k@7 zNt;#Hr2k^Q2PiD-I}1La0wEMH=`|iHKB~G{rC7m(u>LLb_-A6(BfQLz75}1HzHPL@Q_KA+127=&_v>q;XxR4| z?FZSJ8mFEhL_=#2rCerfa}i3|NFCZ*$GW8FTd7h0aA&GiX!bGFh@<;7CSRnz5v;Xo z+BrDm#*e`;Squ(747dju)7yL{wytJPWS&8KC!|)ko!OOFRlR42FI~OHrREDUlJOev zFGlWnjm)*4a&AdTzWx0Tg?V4zSw2F zTNJ+8`Rj*X6hA0J2HZGsgt0C|csIy{_wn-2j*=~mOn6cTmo~bp8mFJh_;@omR+^`6 z+{>t@o~HxR0JRBf8lA;Jlv;@BV1C34@+8Q`|x8mGl0|i{(`LZ5xvN@4d*v(*r3m8C+P$?C1hol)wh1{ zB7uK~SjIe2UqBrmJw>YBXXjRijg2kY5pDVg_;#8ZDx!vrHr-X^mRn&@y$uHHcF?h4 z+wEU{wox!*Z=NuoPy^Pte13JQcJ7px6p0Sv=-7ow`Tq|6ur_kgjK5VEo_yTBdZVlU zN8g{IQ^zTe0|yBA1On)p}knTyh~`l!!4Pur=f3Efb?Lr=C>0$QTzz zGG&LrO6%Lha?SE`md02mQTRI=u$98`57j{5aiTuI;H3oPdF#$jjiTGOA zU+l?=|2wZ{zS4)avXZ115-Cy35UqQ}fDdaYHDfzBI9&j9P`3NT6tI~rH8dlm``gPnX)Asn?7VKb&S zip~z{-P{2Z< zoy`zvN0fD-E3?S8t3#OPCeeTJ8L7CEq{oS2883xR%u@2;qRY1;HSd9{(o1#c&-c2z zMR*X7!By6Iuy=ez`rdwf98zbmB*Qi%tSap`k9rKvtme}O+MSbzK#9t@<9sgMUj%iimqUUUF?PxjA*s-fn za~V(VhY%_XAlCi#A_MuLgun$C8~;t$Rc3p;gPetaYEf3=aknGvMEkNG&(Q^ zf(KgN7TRBE{<`t`N8b%&(p@Vv;;a7~+`C!lNbC$-Ektx&DjQVt64S3vI?t_148yoY zVvNl5J@LG}Xk4yJI5iftHpp?JghCoK7`lDNb(6?E^rFKi%HLyUQYPgQ@s?d7MDkC@ zD2wS#L|mv&f-V>!!Z@zRG0ffTTWh<3m+nfd^{#iPgrSaupaHP*FVq0E=1G}FGHq_K zX@s#_SHGJxsr3zJ}nSXE|~o13%txx7ni$g`dSJO!aII63yb`d!lq zSBpl^{IDMcmZg2ShJW9VIH>)`-pwYaj`o~C5`K#|<%PrtFKJHYZ}LN1qU**Cnf)p{ z_d0z-MxI{e9skT|c3+%#`!o`2n!&aN$om_X?OdL@>Z*aXxEyM8V&hcqzI~i7USZ;C z*GbCq&#c3`bj}?)()jjl<=!!k%`SS+=4aM(axi_Zp0LhU$?f&jaN}^I?ELD^!qW3k zw&?F&5wQpZm#x@^&@8{AJjkEz^GWnGH=%PB@?W&xHM&J1fhZ z&ukqIvs>r5K})IWldDV3qCryWCMqKBi4aE-2lv4+${{Led-K5gp){`U>_s7Cwx=C)NwM*DKWA-`!e#>tI&v!2@q(vb|iBwVNy=a;;O!>Pslt99yemz`k`1 zDrhTToAiKg-jM98Js+%B9zy8_*D1Q8g|4|lW#~SQf>la8{pCsvC&q497!z$>k6Gyb z(&g>Ch6P7kx};mFjSgHk2J*LmPujD5riSZ`y}Ec(Y{724sj#hd==eH??yhkvThe!Z z5Klw2gcZ6rZbZ%OFFbM`->&G4lJoPCj`u8kQMG?FwUGEDNE;<}jzEqRZ64Ld8#K#3 zQ;f>mi|^0bS*Y0HJj7R$a4}3yP5@lvaMx>J8}HTE-joSHIo3PmJXRT;TCN$v3K!;= zcPPz_H2-GS7BZ@;%&+ub{vTV1v)>qMd5=rE#l=znr^faYJ!bB6ic1F&QMMN6&-_M}DcsA7ExLl(V(|FN0 zv^rg?u|YjAxiDQ}|AGIGTb~_sR^-rMY$yCroioEa{Isc7xa#6XQsA``tA-ja7d>|= zr7OE@;%;nf*ZaH24lZ%Qnk#yXnMdg3PJS7Ehb;o%1_eeK@ue7oe05Ifc&74BXN~n= z-w2?b_H^DQ&917l5|%kXZ;cj9B;QXg5qt@$H)GKYnGB1@%Qbrx{af;zGJ5WwvtvZj zcb9Zm?HTc%@w=+p3k$c!W;tJ6cEyt%M6E~_AjmI-9YGC{)hK64?^*A3PXBw^tRa5% z@lwSRGt>|KMqTtmNbsJ+M;qXp$YMSRD3A@c zDcZF0qc$Gn z-I_kNPzu16Q&4~@=z*pRX;~PwBe&7sJS6CDkpAyV2S10j|Fio1Rg1Dx6;J)hi*kn@ z?r#u>88ZML03m)st-NQ*%L8;1=_3zTZ44M8h@D#|53W;nf#5K2i3an_i_bDW4=0wO zTK8!%d&dK?4b}tkp|Xise94(QbNnfWa_deRM%UkfES+Hw=r^M5VPL0lGIOSA7U}}( zUwHE2qW*44O2I1@wB3xT_RO{tH`Nm~HmS*pPsQQHwA^$c(KWb?`L!!19Nsi#Lf0JY ztFO1+)(y`M*`UHW3+9Yhw(4%wHs)CJ3lN$Vob0jFiSJ4Lp$@BZzUNoMF;S?}cT_h0SsEwg22QO}y|A5ZS24Fr(F#HrY5HYZm zs@q^>=har@1he@QPtOZpaWLDeyF`@QxkV8pt1ByICrKf5+Vy+&MGb2$%MQJscsWfX zxwMXUQyHuY=aw+*qzkzoLdkOYQ|oA-eHwk?t4);0`{*E;!*BYsNw8puu*JE9(4s^h zrl@!njhKLleg#!9;gZ;q*uviJp0#BBpgX1!CZDE}3dxB;j~z?2(|5;=plqU4V1K=T z^B>&}C@GtYktUhYw*%o2p3!^FuztPohvywLUGToa8CmO6wiUJw$`C&O`?`Cd58a&A zR8&}ag34<^8$p4K;6zIf5e$YAJV!oje~>ig!0Xoa@Eh!&39{nKUYJznAsN%x+6cgE zeGe82k(v)hUXnG&+RnsycqF5%q1jebpa{vsMtwqie9a2d5!|SO(ZiEgANLGLC?q7{ zQW3NpFnY_nN18S9o|aJ)+(zAl$#T@{R~#^Jl2@t+I)z9j$l(vJkV?Vb8m_#Jo$3nz zH4wY3FBu3v94;tL2EteJCq)AR%veux5yF!3r%b+ILlYNj;S?TDa!sSa7=g=Dx80_R zTA2APdo5{*&T+Awcv|DurvHva$38C&?(uX-wA`Ni7p(5t+s>#+NXeTuDyV*Ze*S>i z4Jy9B|9#$WWmx)}jc*U8pZ=V`YI-*kBjpwLb2tfvO(v+M01(986J1rERc3TVDVj9NI5$7|5Es;~DW%c(*#5cIwf zejK$DZH8;iIFO&6?I9AXO;YbURayVCSSp_i@Aqh)Y?RYV@gfzDqNdBF@?^( z*()%=9&kDq4#+baU$L~)TBDfMUS-3cOHjRj$|3%)0e9DOvh64)8evvqWybt&5SdTna5i@BbWEJg{D zj|BjOCM~XS*8gnYJ}v`M4bn$w5Uen76BUS`RoG}bipe-LFfQ=U23Dq%EjjGa8FR!{ zXhM!8)Ueb|O{M;I+4GgWPgIb_#WN{%Yu!cICjkixytAycO^E=?+Y5q&u?=as1vaRh zZIb-mba*V9wQwpTH=!P%-xB@-7pZqSQj%;Q%Rs)VlIh* zUjb~Phd$UjQ#cm5u1{#Z_(FXV1zj7h42<-HQ+VK5Vh=ISO&sA|btFW3h&>rhRKnJS zA+(%>0E6%5y;4Vs(nG2DUf0F^5D*kSK0r7%)GepBKtnU5b>_v5Gqp5lf%lcW=4kJ}_hZ?p?bq zSV+LHsNc9F5MvDHFLpZ(OTwlmyhXRy(M={LI^~4_ZecwZS%XE#UL&DzCnhkZ z-3LrrE}Vtu10JdEZ0A>VN?=MMp)N6ogA6xq)&xdrtZYo zLbhl{7o7DlS`AH^0kWcS9-QZc_eMEGXdZCH-aN8VivGTXH~x2_cC2uTCE<62A#~sR?=bflI!)4{f)B2%lFylC0nAPh${-}OB8yz)=3^%g>T)1cIX;#4t z(?oW1!_rVQDa1nJXOv0h3&Shror&J9bH8o=O2px=d!bYkEg_3E3MvW^Au%k9vWJe= zt@8_4;QXS-tbPWwjH(Fd58JE${pBoJ8ge6LtROoH*kpJ8do$p2`bWL`!m!LgO{`U5 zmu00v`93eMrk4uQ9EeZV<-rsQd6o&_AhGH8TcNinwPhqdWD+~$s)WrZFi-(ml^qWp zrot*|Z!8+B5SrBVzBgn!%b4v~ShZisM_^dsH{>(C3;31%E6Q>urJ=g7F*o<2Uwx?3 zev}RJzsyRCGBIt+^lW`_&iONyCNR(1gta&?m$~k$(Z z185#~>dCQ9vn6m%U>Q)v6a*9<1eE_CJ{SEHmuuyqeM49kZ z{VAHn)DqAk8VIIErfiEVF@%+Z72$WmUYk&;c|kmVJWHr2;kp#2D&`Rl@nheE>H z#x6oOWGsk?EAy3HyDvN`Zc`-V>2aaJRBh5AmMmr`Mr`*R@d7i5*5318s>;XMsZGEoKG? zXd72>f~NyC(H}DaPm7?>ey|LclU_JoOi&nhq9rlSQY;}hxwLmQEzCE5c5RY8hxa{l zt;9hgb4JO>0?w$Akp&fONC96PTROfSK4K_1jE zZdWf`67e2>8|IyI7^ppZiOk&YhjU-pG3yN@0U4Y>Yp`P9rcDwU0h!?X!L@*IWeRQ`@sQyEe)=XLFAV1dNl8pq{Ne5Mhswe6 z;UjUQV0Suhb}mn4%;_&0rcyu5gy3$Aty?7p<565Z68+**UWLI5xHC}@ub${FlggPP zg0kqhl$DxOOwI^Dn?QgNRs0rJ5(r3$*}OKh_PEL%1C2rd^;IKxtI3h49`2b7p9RAL zzU2Umy@cw6U?}Ve&CO0zUsE~;{%{fBNNMF9lo!EFD;y$-vH*~D@#g7Ut!S9wS(rUU8$Vm(N^n8-|&AV=ZvE#Vr9 zxfBN&(lM62IYafIW{FXP4=1MVfnRX*^y$$}$ny9%C{V=3T>=zu*dVLZgvd?XQ|Q~B zycAOghcl{x`qTqIAFlWM+tiNOVmS7f93qS{5MkXxEz{wWC2a=87k~YjfnWf5Wrwx; z%=l3)RV7xur4mK9M5;VQvEc2T&=2%NYR;)>uran&x0mMmjlOpSa~RTZJKK za3!3W2%u!%(%*=X;7VSV4iTyPdSM|NDlH_}Ez{CXqYNgi z-!hG$tob3H8zv-)!qwSXj!G2x#*_hkHia=Q4^no`lIttP+@7*T{{>_9Ej-gKQ{XKH zpNnaa-q^03?(VgF@5@6Y0iAJN$r_|!6lGLg7Xu>_FB=1v3auO0v|zP3_x^ch6=wLZRpbz(GcRl)}hIF@I&zsrP1Vf?+Z#@3$?7JY)~z|QoEovvh%UtFv@V= z;JwzvW(H(pHbbVqg#8bT;{LeyFk1&ML!q6eR@`7?vVQ#_h1Uprk)jCaNiDgNiM@K2)pHz2A1Jhrwq=HaKn2PBu*=N4 z$oC+-9q4E-6Rsg-I{>YKF*9ZyNovneN+Tg2ruY`ijc^_k-T+Ao4z1(D6nXCFeI&e& z!X%FZ54?sL=jYs#2Q`htF^yWs)*n*x#gh&_4sV@%XHBQ@5_9pvn6B^e!wK7kd&~09 zUOf90Y8-BOo6z-RxmR^UYo2xV&G`7^fI<|=&Da@%N6gJ&$fZd2wc?_bV`-L_T2PbZ zCQ*36Wl}l{^3_(I_L{@)tugN0-0I^v**@AM6-*<1aki1B1>xCNi4Cs0MzUmektW4! zajTjS^uM@(>6cHP^263P8U&VN`4f6mEj=E`mQ`7Mbd$it2<(QCEzL{nn1KZ>( z=nP>&kP}8qZgC66HzPb#6s>TST`E~8VVFa6e+ZNT^WlGPiiwX`zRT^(295iQ16H(9 zXi+!9To6))N$ZD0Yvl7fqfw3qMs$hTGE}s!BB^7N^pQg*lXWMTlV2d3m}N5bdFHcX z6f8^?AR=+agjf|;O<<~|&i0zAIy(MDT56q9Fl;H@`fx~`2?8!MpsYh>!Yra(TSsG3 zI;#s$_dQ~P-G#=94Gru!$_);2+TAr^CL+HmqS5t_K@*9GAe?G0?MAw84Hu&+)!0qY zABLe(HFmr>xq=?Qd314tSPW7a#5Eq$nOh0E5V$!*SeVG*?nQ}WEp0K($k*G@ZfNAa2}f8`>KlMfzLvK zCcsok>73f7Wv!qhMhcX$T|z?dv?K_}6laTI9hX7~9G2~mBM)VMcb{YqF%Jk`; zpwoLBRH*n(XkE$jl@quDGb&%^y?bu`KW>aD6Sj;^suBaH*{I1`?yhu=ms{;t7;ofR zc1u}j@W~Diod;DIqrzAgVQv9S^ZwokZ~Yr)hK&`6PF7ZG)&A77`d~mRJcPKpPc+B4 z!|7yO5y>k9lh_CVV0^fvO=|Yshdxf*5c75?QVO&oaZrc>_sWa!=FIzL#*zq<@sD2u z69Phb+;&VGA*g)bm*uXxs%#-t(C9xfkLri#{@eI(xwQm29FF8*Ov1+N#e~eM)2DYM z`a`#hu^227ZeB19D2r95*DVzTsYqCyTgDz7YfT85AlhgMkaW)@ttQ&ZJ4DM+)^RtX zxG)(%+qYoJF&cr(WRGJc5T?^dN*%RQ05zTHPARelB`2}UZqD~1=S0|=HXNri=gICu zPJLXXe0W|!OSEU+;g%*(|23UqoD5OBlB3C~+;Q8DY8XV&`e{FH6j)*<4d`4G#{h zlS8?%*Qe|G%ulO=*Ml=~nmpOUkA9hZLj#v(_N-ry9TK}SAXRv&(@Q;;U7?M1DBn=u z!IsWA0<^E4M1O^b$wPWeAgPBYB`Hy9L&>-LNC5_+mbvVCLJx_=UU#^hG21l>^oPo6 z8Fj2*F2{?|2DgRVl2XV3wt!PZ*xosF1lkl>2+0s)9Lffj&SKg*4g&KK5^IJ%xy5ZO z2RVO)NUH9xHz}-eV$gxazi_xhHWjb4aGp^dQGMGvXhl%y#Yxb;d8<4>O2xH>`)d!| zue0R#+qP{dX2@Z{X`Pojz_uM~@CPFem%cG0s!3(Nw?0{Bka|XXIj7ut?y0+{F0_P8#>s&OM zXj22&gKR7Pu+;;zbgCCk@(DS%>+!9&)ybx3w!&o*9z<~`ZZacM4O37!g0xo9ko(}z zg!*|K2k5#^Cf1WI{>uDx1#3+FcLmf!bHvlQ-Zm|#N1*w}YU+w;lu zfFR7`8%nK>&{+w8)KPqALQm`SniM;Q8P7fa3p90d>hrCm1=fG{YD{dE4%F`-3-skv z6Y>csObD>tDzX&i@q>?hgmJx|;UKUXbix=+%}kkkttt;R%LM#8jcVTfXx zw%#RX-4EPKD%7ugRXF?qM0)X`I&M0cJd-7~6@vct)&PF544X@Z0ok!-8c6KerSJ8h z{*+r}todjD&Y1(6w3PcN%(8WxlJo1rfyfB(rknzFKoTr_g`aQE366fiT0$c+1>w&? z%iOgu*oOU%B!T!!5=7P-@VG!zamMw;S$D3vCH(L_`mU%bl!%mb_Diz_4~e2BGQ5YU zwxW!HBb;baK43t>!{#w|A*Cy@VZLI1>WPGZ*O*sb>>NGH#y((v;H&MfHZ_grALgvW zda4dieDFgobGs5U200`u_sc%&C02^{@EQ_=T}q=IO1v2Uk(@x0$8TxBE+6F*mmTg= z?V0nuJWF%VPmzHP^N=omm~a9t?X-wH)La0do*7A3w+D`i?!URlo&vd-094nhLpz%sheQtUVbsyY!GRjyh)$N zyX_Zena_~xOQ@k_et1m|N*~(K&rnZR8<}iapQC!3ZSDaT@2~C+ogmG?C5n_Z>G41Z z`P9@;`_URk{{b1_x$U%MJ?tfv{~urbh1K*bonX#4=Qw%#xhni zON(WWHZ^~;;f0mji;8_DQcf*22IFP^{9NunjH7`*P81TwkL0*?Z8P>b-Y}$3W`+*= zVIf*0D~XfV{6(kJk>NfemtX>gqe!4uQs07;hjI29R}pRKixJ{5knm7S@v0-;MO&7y zTo5F`6N36K!h2 zkD6Szbu=%F$r6%?N$vz${B)uW|Bh5RD~h$XASEErs9<;(?bT` zZ?UPlM^7TO6uUY7>CTw|8Q;PE-6otE@otU39U&RMgE|6P*bA;x$h8y~X_j?I%*_jK zm{xX?0IOqSx}Wt5`SUX(r&ex3d+VItphGaOU~bS)&>{sNL#Cj=GW+AZ-7%YKj_l`E z>N27zrRti6EmjY@MIHA07H~y!HmLl1&|;GG=61sqDN47 zmU6dEI+Le^J<1XygD3d(jEUWFqh{YbNJLz@Y!39vUM&BQg!*-LOm#wfETpZRUkECJ zM3P}>vu^jzNxK^9D)QjZ@QRGQm-~;U8VhmS@buY*)@S!fSlUb4i#9EO>e^E|>jWrz zHzg#9D_@)28i(wkW8FtUbcSk!E9`J`i_%Uwz|)wg)I(Ips%H9-sSKHE)VZnSmA?4$ zKqiY=N6XQeQ`gy1)CtRJc>Ar^*I`wY|HR-*QZ34;TOE0Ndthnl%G4&r20-#X+mf#E zstg;e4Jc~sL5FMQ?Xe3vzT7xEjk+`_R_U65c8AlEV>K@?n{6C7H!y%hhXM&X#etu1 zHtA=Ti26x%KAVB{%6stA|926OnMw6%_C5jG$(E!~@66dVwvEpfC<>sX-k1987)BVU z!hQ%S!&-or=4J6cNhmO_HSzWRggz>YMObVgs8D=4v0K);U2};Bx*&G^P^Ea1?^|)C z@o``dlDU_Uwj~}2URN5#Aqw?lIyv0xFnS8mFJw#9;S>zhB3=a+vXcaSo;|x4_7slI1X=JF+0MLipZd$f!M$?! zMm|?)c&8mcMepQTS|Ej6lh+fKx-hrHhEkF-*+MZ^VMhPS3kSlcp`t+J^;>sF3~J`D zK!;Mp0%k1XcqkZv+^vVo%AJJU1d9#joH(pKyZ;nz%&cn<)J*+XvxhyG!qSCm_+jsC z(F2G*qwbn+?GNLJA4rTT1PPIHBTE9_AKNnV2!s~5R!NKgYey}xKbdOz;0u-7Ixd}^ zGDqY-IN$*`jS!>;!W_3VSSOLoUw-+vckP7P&rmj*;~e1rf8 zOJ*D{Y&@7H@R;CDdBj9mu`GN|!n7E8;n>zR6{mO4@7F9=l3th!B8W0*Z}tj88LjDb zdO_z>BnY}pR9(=C=qHxjx6j|#Xuf`ZDzO4b5yR&W1+`ah2Y-sMjU>NL#e9SKhS%#!jJ)V0 zsyoIBU<{s~T1zX!1PWmmmvk|lIi0q&tBE%3a!b4Exm{d{483k8d=O~(7`8{rzSK484uoRmgn%Pg3ZXDhaw#og674J}W*2bLN z)5CK{#%q{^XaNKJVu!knXq~wixJUsCn=IU)ObbE65q@Y1p)RFa8p%H8V-oTZig<=> z_5ss6M;&Pn$oxlL39k&g64B}79w+lUhLar3tKfWt0SjXKM!ztaTsRdGFd!44!Gp7u z^eDa2Mp(fp!MZRvt0z4oVy*zzEfiliH4ioR~cn~p$coZmri z7Wnz;3wy+E)aZC9_7pS1U%z1NMN<#mT=uK-5WCjzyEv5pAA4^ePW9Tijnf<=l9Z`r zOHw3BWr&g#X;2|Ugb1O?Y$*u|$u3DKk|ZP{bCQrG$wsTJSjiMiWFFS2u}kTA9msoy_BObrOh0OAm{H6j2( zFANq4BUsxp5sp5IDB7FTCOgxJP>VS%!dwZDrjY5mYDh>($^cUUKQa`c7aGWJLei64 zR#j-zoQHSaa%?H>I|!snM%+@2UXwX*XrB-UDcVqy*;GGHeK%A_JTFk$S$ z7po!<9x_18gw7ii3LuIhO(FOxv?XAB9L*!nOc6dPd)H$HVOT4mHv>TgsTesn;Q-N= zO$m<(c_w*{$Mh>IX2^OVm~JlNQX%3d^n65{0wEB7ftb~k;^XI-gI{36(NYJdHb2{> zJ{xb)Y0r@|7d;KPf!Hk<5eK)r^pHVDklU7)bC9{n1HdUEDECR}Jrgq+_$2fU_=v_v zeNr`{)0o1mFpZ4j1Liqlruxo;oqB_3`)FGS3qsxgk{>)9r1H)4Q1T(XR-!@&H-xGU zLclUCcSIMA1NK%k$E%6i$+g`YZ!!R8qi2Ko1?h=sK1PJXqYoI@HeOnenCUJPsq zr6x2Z&;y|OAbaCup_c$Ee60A#QgqCy0DvN4k(Bk&DL@~K^@$jcst|1%7CNp5j@Wy6 z&wf*4RB!F+qz}d<7N}#~V-BJc&_*zPB%)zI5riH^C-yO5(BsH@s2JcFQ9^hDAPng@ zXR#pIA}t_-V5|f?IB+ksb6mHrr?~%~@}S#Tx{Jx6au&px%Ys`V`avurchcn6CIlZq zqVnvw$(|~Ip>M&v#4jtkT%5O=nKunsAHpJp&*XqdjE!A4-F%NfmrQ;Tv6C zj_|I@%^u(_C&sqrwWVFnW3o3?w6O4LZo&c(2baqHiWM?JT5I_WR^J3NmR*IP^fXMoLx!I> z6d(}Z6#_SEE^LJ|kgET%v(fFjI1LH|Py`}OPd^959Y zAGc102gjirCjlFvwgfMnm@b0#MqC1ggwhvd5mPmHQ+D)pJwo&+u+0$Ug!zZa(kFIk zV^CpCdj$|Zr-K5v4uUf>1wdBdw`aCfr}YcK;i0TS3x{&gx3U$J={(d!C1qIHP&Oe= zArG2m`ku~_n$blFt3!aYLFEyZP*IVIUb%NE_-7Z0}Ghv_s1PnPE@kcVldZxN*4A z;rHMVU~MuBpdKb7v#D#d5{-`oL&w$OJIo62m*wGQL~0c}1RRZS!>%Sq0H_{eShJy| zwVH}q9>7wN(yGZVgQkICv@kis;WWh8tG*s43m1Xm??3h)uwzi^3zdPU`yuN4c~KFf zIDURAJIKcy5+W=LAn})X(hIvFXn}@o`@@TI!g&R_O1L!LphB_ehZix`OD#%^#wh2! zS%i(nqCth}SftxFiNhxV&XHqI&m4_b0VWQ88m7mPj+9UP1*6}EV9!zdK-xLqp$F3= zz$gGOsNFg4Jhtk8>i)lf7bn7nhS1n&*;mZsV*!!?3ZnzmrUdN*laEQybrD#+V2*tO zSzspsd`^#!pYiFW!vG>CIr$2@QNT|-0ycw#hN&eqE=c;A#(&!lgk#zQ1wQ)R=yQP_ zpr<*^bZ5BF8q=dntES;GAVkPFaaP9|$2l=LObRh`St8A^ zZu6}$SA)CMn>Qf}-qq}V(;Wh6HzYj(FfchH84Pvz^h6XU9*{Akst8GsG7uy^s>iYC zr6&X2QI8{)U^SAPLK;O^i@U{q9HH{S??R-hD>S5+l!KiFumQ3f^bF8fVOsz}3R73B zj+0 z+CXE6G`6(m*G)GX7)B1LnEi}>E|b%JxVO2hX-V9Xol~7GDlasSqzVh(iSCSBCPh~g zX$cke6^oI#k5WIdGNWCZifPOV8l8-JlICVgZ(;AN=DA?*|$B4e?FP%-tLNPAk!5qRF#aU8-EnB zw(^XvF&^3R!e_vN&8XEV_^W9IMNp~dzP9I0ZVtLj#!l1#x#s6U_(pSSR)hgL{s{Ni zxXv3o)lux7w~*aPzX=jTWJ@CU0r;DF2Fa#v@u}zT)Z_ii*sh5u`Rjxa^5mgYH z^8I4?5g-I#&~Zr+a2-EXmIS6ft-kcZ{7yx*vDi7un)lm^Fgjgh74T@(bPP;<>G+W# zrNJJ5F-_cMOXmCjR6e=;{XL)0)Y&EX@40TFn_Rc#=8{fDB{SzamSjx z_4iBHblP+{ZE;-p+A_mcd%zfb4%fiCjkixw(YSK}!c!vGJ2@9S6a6(U(>z^|W!uB& z4(|sNW9AMR?9WaUrnXht4SY=MAGmL06iw|^elYZ-BqSTE3(SuU#m4tUcV?m#d9hLZ zSL(xFdE-_ES=qkmtdfxN9klby%u?#R5W^76$uGP#-q~Qlt1J&**kp2d-_HHKKnWoF z^SWc{oaZsKxVjd4UMsmB`}C#&h8_W`DfK{}9m6Xn-dJbtJV^9(g7+s}ytn4srS>bS zbIj_FXKph0=*{HpDa#lvLQT7GxG6nZeI^3lJ_*4blPb&pw{0qnTS?S)B+-w@wRJ6b z4#5EW`l67V$E!Vp3(90-tGA;!qz~k;fAv@@H&mr-Bd>bnP(%D1iyT8fp&yx;b+T^$ zZ1Z3Z7qWFt;N)jI4%9BJ-(S5Qaw?ae_cNjjOgm1bTuvWK_g6Z_a5g~c#$xoT%y-3L zeAa|r8_@%eP1wTaP;8H008wlA1y>vwZJ#d)!-Zu1kD z%&@kq&CaJp6uCN``ySfeKknXSBXo;ey{xwXYe?s@^%X8_?~DB=oAM!>7A6$jsq|I- zQ6k(QM?K`5nsRuL6E9Pqs>m-2+j+g_A9Tf?pT1_Cubf+S?;?fk>~@tmuEyrEq3+|A zABRo^r#ISqcXbP9DrM3~i#PY#Wee{BZ`_%{23h1_7c^`F5Udk_R`b`+>FuM}l{)g*Q z)7K~bGE6KR`p4KpyrVw%hc*tS$I>!oK9330hBk(5vUn(ML-B5J`muTVpu*f=*V~Vh zM7K{fmyaf427Q{E0KE=UskyX1NDlNKsrG(BE=NpJTMif zHvVnhZx;5B9Y5&L!9v)GY4V6^;xMaqF!3oI?OZcu=&)EWs;XW|@t3zk{$f;E1~BIw z)aXd{5D|b)r~G;&_?sW}68r!GpJK1yeNJ2~XH}Ol%Wo(w{8f$4d=Oc`|1WLf|NE!^ zc{2a+Y5dQ#*sYrcdGP<|rTqPSX(JS8$#tV|sBYFoj&Iz> zx&m9ae$Rdu%^9dAkVjPn7XGpeYC=PhpK7eer@t?FXTJd#p?eFAFZET;<@JwW0EKOW z=RSPa&;PlHqu9YOC|shm`rn-&caI-&7FmQZb0-#NipJ8=S{`#BmGUfn6Y<#d*PqQ@ z6GbKrlIvP1ew)NJ*EnIPU4kvW-#N3eoFsDLS(eJOn4N5bMvON57&F0mjQT zud;lun%}nwR}S7icxMKK@~^4S>2I=9L8u2Unt;Ca3g^hOU!DdxcXvDqi$CA$^q)sp zoBphK{^w%a7b7nJ^M`-`|Nr~{bc0uYhZ^y}Jx9wS532 zg~VdE`}N6InK#os6n?X~>>oz(eMzf(kmg}RzGLIbjZydOZIIRX@wPg1`TzTd$)DPT z2e2TdkGrwp(T%c(i*38Q{9Nq(U%xOQrZr*q2u>@kFOdG{tvA@7<-aU$CHJ9TN$}S< z5y$Y0lc2c?NUtrom*F?*D#qK2t52>wsb(*OwRAc`_&48k!WEn1bKa4~zgdN36-+c( z8xOJk=JLp2dG7|w_3MX{zj62bum7|~T=QySzx;pw_y6Tv-Tj=>JXogQ!;jkA5|@S< z1(iWU1I|BRL!8K(sRfPa?EwimG!)o($j;c&=nmo}z&qd_p~FCRjw+Jl2iYJ0`Cm=w zvgsUGM*Odn>md087cu0_P1|!-Y`QIh=L?z>tIQAevR{E+ffB$dH?)}%m9rCTN0x<( zPVi2P^>88xx;{`y7=%VIgf526yTOzhL;2v{z^36U(mZ~a0`J&C6PUh<5cnVgNFW_P z5E2xiNoCM5f|Jbq#EFlEIv3C`!LqF63g4}xV-^eD1~3YYRF$^3-=C*hwEEKDeGIr|3O#oM~N+0YK5HwAJ*208JJz(4wwn*C;bd%vH4 zfm`kY=>T+VQ=i%l_!&6Go$dhvW|0%aQu}dWeS} z)OCg>8~w6?C-h|gP{&$v(n5a#Xb>$wWRjrF0Ad3m{4m51mR*VulR$76yo9@+rgUI+ z4@L12S!2zsXuU`aO`}O4o`dI>HnwlZu=vOBp)oLhA?V*R8$@Q&0nVm*SpB%V`qFu< zt|{$0E2tPflY&&B(Zt!{&bgm=PBRc|cYxFxqG0Aq?{s0Y%B?}JqP<8X}dMB_mw z0bxZ1*%glQBz{mBX9egsWxZ0lrt2aN!fedY0KF!JJ`T|ip5M1=89DT(1y+AVWd}@$ z+HHA-se#Gz?~F%j1wDsEf4+_YL$V(Nx;k(mZuUYDn&?gU?%o^XSpl1yyMEyJ6_Z@>|vkq5$pE*{J|`g#C0 z@bzoxraeC}R7rl1ff=4aBJcpG1*s#N%9&37c`DH1aK{oVkO~_nvLt>ElK&P^xGnq~Ww1Fpr|HbdI zn(%1wi%7n`-of}fE8$HVBo)Rc`*|Tb^y=@@Pu?tXX$GDLe$cvzQb!z64x9-gv0Cv& zr|bL82h!^C_d~%Yw(=@2>v;Y%IFjE_EJP1N^3qzN#g~pmYKbERfeJ!sCBvAI zB0hN_CxMIt0)$j z_Jy8+q6O7zlCBzlgB%0ZR2iN4kwDxAE$#}?PRBw< zAX2m3GTa=32y9j^e1ju80R`b903lfOy98npO9n2HuwWPgT!+jFBm08bCP&TV08L^i z0cHrv9!`cBU4_Qdaiq_Jq#$f(D9fhOBROYoxQ#M-BA+=qMos`4oIPTp{}R|I4#x8X z$&0sJv%q-TYlMuIk?}#Oyj&GyyvDEwA(A43mcQrCA?tObWK+BG&WXm25T~G_F+)NB zkZD6k;)pDK?l|!^$D4wxhv*wkm;)ws;3N!rIiOfOTq=2n_ zRKs3GM4*M4HuW-?Cxck5M%4t?XOPlB?_}n^Ir>dv$aX;K;0FJ8&%it&rUHTE!ty5` zGv~;*;6|`kb)q*o4-nlOUKi>D5Zlw{BIvvjiHJCg5aHwqV_nl1UH2r|kT#G*<7-^+ zv^{9{f*RhpxOZpwlAlAHm&$ZyGb)VM5Th={4hHcI)e_+V6hXw8_@0;$-TJ zQ29YZhLJ>aJP!2Q;3`q(!QT*iMl$G!=ms-b91TK_`GE2iI~+vBv^oG(emv$!u}y&e zj&D6xo--vt0TE5S37}sg>Ig6-%CSp?eiQ+$LK(+GbGpZ1Fo=IF)S?ixffgaxQ4?)q zWW>q>wJec>=FujIQ2>%DZW?en;j+zT5rT>H!r$jM5HjRc_DmMMC`|c4BRsEX>vV;| z0wY+=hZ9d0SY;UUZYJ|0bmW*`97@NHdn>`VN4!xl1~m^|7vX%M-2yNT>JN@#>TSbN zjFYjuH*bhR0bV8sM_V=`Qs9C?c3i1!C8wcat`!kIpdUYu5GKSigLq9}+f8alurE_e z6liLm;q!5;sCKz7G+gA(@?G zcc(_oP3|Wb0*~)-BC;t6t{@=9oCY)3fbB}+DkxQQgcBw^ zAP7Se!0R9(6lES!jI#KEy+tZ0HEY}?zA-<=du^mv3)%v5N;palGLPBj3Y`QtJya`! zA^k|;NJ*#$S$wc9FQ9VKogNhE3f@rJA`6-ZQ$l%}+==ZpdbrNgg?NbJS zA!3q-*g%Y}341)1&54i$IxEzoxRKJ4BcVEw6yPr$F*FI`4w3)a7Yy)WNJ4#m86vs^ z6hCB)0JCJ%M~}g}{JI4;A<0B!i%V4!(E*vSNBD(22jK>q2Gl2)++6j_jg(ASpisLE zaTTeA&=f)GK-xrNkJD7Z4o{c7PwOu2HQqFJ@*R;V;o!p-2!mN19?Bpmp|=B@H9S&H z;Y6twTRpV%+pqDH@^}2?!yK8I)&5#i7iCQpwb zv|DD7yqJ(DW3kw@*cg@>BYJJnu!0jceCohTBzVZZ^HB1UTwJUQVK%lhk{B5`AnGsZ zLb1Mx8x54jC=PHECOKLM%0UEZlrjkGn64z^9@MJDv5Ay2So6~e2a+}R4LP)R>J8$g z^uKFQr(6n?|C9_rQAouy$JkQg^bMOxFja+`}(2;>a>VN)XKEVn$?~ zt}?k2)&VO+9N~oprKbbjk-0C@j=sKgmQI1f1Zo~4_M5UUg0~SGD>OqpWN3K1#A6Er zJ~GjP2L&?>ROQE@T_CD^q}e9-uC?R!%uy3kwOS{&Uld?^r?(aH@i=KZF`iQZs1 zs%S(2o;cRDbloGi!zRow3OQecGFlr;l$|(Gb5qF*4)D5{7evzIS@_L-x1g$QV8yDKQrO84E<<=*LEj6uC#?Ui*@Sy?%ucJ?Z zxE;LwWt1;5Xb+z~^J(`sk>CX=f^H0hIB3Y!@025qqwN?9hR%hw*WG7^virO?bFl$P8;yKP9ju*LbURbht&4mSxZ_BMZiI6mM>cUf>0F9#gMk6Wu zG^FTX@N*j$KVjgjKUA}N(tTV(em>okikIjWr3;PR3qNs(@S3s43 zDp=j+hcBx+b{mSB*XiFT7$vETu-|l_GM~2XLfn{|ZtWh+HQ0s}OA541gIMb`)f4a2B7;Gh7FkDjnXNf_Fn8Uxrjc(7h0~7mistSjy3vgASY& z#i-QD!F(uv4A*6+DKtCpZ~Fke1JBo7AIc=?ayIuF=(d^S>xl27j>-ftJh<;xjqRO@B*R#tG6OoX*3+bGB%>afNz7%8q58q@ z$Lq%;!5mLHC)MK!%ZFr~MmU9K1#HXtbV}v)R@lDcO<-kXg(V#5DE|&X4KWBRYlz}d zSrHL3ywJ=9PbwI6H^o-B0XVq>`4k4?0I&kWOVTAL9zw2|a(L|J8%7D+toNcbgLj^> z;y$~9(S&5XlPhy<;ZKCbEKz&S`8)fBZ zr7M9yn%;^C=4;Bf~#xb<(A}8ZOMN zcm6)&34{LruTl1WT+CiBjNbRygsV=G9slt^U;I1tzuYx$U#{X)eS#-w=hF7B_`JNR zV=}wM1Yg3aD!2FPdPt(2T%6a-D!%S(;c4DAJa_;1gFAM3KQ*lXgD(i~IM4jp+d5uZ zZkDP@{fILlw-5S8-Y(eq%I9sI)`1j__`MR-bN z0J#AWATTYdi#3k$mrP%U;a7;RvtaP%DnIa;VU;LU9_fzHd^+4&mel#9hgH{^p*s?2+_Yqp%Zcw7sq;g_%<_ zg^@qrSBXPAgao$MJ6E)xMm2|nQ+J|>!WZol>x*8KrV+m%MFT$QTX#1`%yucqt_rUZ z6krZEWHUC~bw6?gQsZ3|i|Gq!_py=gB`0^;RA(0yRH0YHVf9X1w9)HaG=)EpD!%mG zHXooH=(ciBw_eKTDfOMO^8X-m&uf9Atp!WHv$cFw(;5DYxL25jO&nvy-`N*{cJdwW zQmB8RtG>cABY*IN&3GAxqfpq0hwV~iq+a@0MhXZhKhs+~ua6R;bz^LJLWJYf*o3!M z?T;C|Xir8Tn)1&Y_vWa5;V39oiN|@*+TA3}?l|H^EWNnxB%`f= z6;%GdnFG$bdAV4LNV0FVbv&9sUAuOOlZ&gsA)~wmZ7cBI92|9uQ$FcLBr3xpScW#% zjDHR`4`vSfUK&;Lyn0l@|VGinIzFmlWlInH9hJsR&scKv!)1;NR`R430iDDecs{`c9M zwEX9INjW$#iP>E=u?fe1yoPI0*e>9>#5N2Bp_!@aIpiL6t@&qBYQt6SD+KKb?F1=y zV;zS%D#ApjK`<5+xd6aZgjT_HEWv2`=U`o;c7{7tDL#)}5U>|`K3xU5i~{@GZi0y` z4}YEsv$BFi;==jjaOuaxRGH||f!-3|N9+V1lS&ovp0NO={}!H}x6pdDofT5-&V1L{ zSb=i_wZ+3t-Q8~j1;NFYp+7+_?#}fyb2}0^YCUU&c}c5Q3=uK3G~i|2j_2ICjH<)o z?gc=Q6Lckkn1mkU8yK6wgcHwq1%6~qea|sC+0Jz`$_@O^%bd2E_Ggiz}@3?+}Z|k+iY0 z2Kv~$h*Ip@iYDX2pI2b zYqd@6r4jcJB)u4YIS0XSRYWz;ddvZw5I+@DT$>*Z1Zx-OJL1d;q1_4Z(tWOX<-(tK zbZk_? zyy!oFjxWao&Z~P9930by{0}5uwxd2xO@8oNMKpx&n#}M#+RfsYm*PJ=2PqCbM*jDg z0(#d=DI?NMKNp%;M5yv;0H8+0dTdGzN~j~$VUn&{Wg?OJ0)TJWt}lVjy@);}W)&B+ z7L9#k5LAkcBqPLNP8(Ry*w`2_b3pzm&~7PvePCbi;^fqu5jz2Dd{;)}7mRnBdwa*2 z%WA?X3$X}+4!aoVctd>fRnMxatsmSkW`^z=r}e{{o*0Njw4sAkPo@bfVJbsT874~u z4!31;+Gqwv@>;fecpBAveQ9W-aDJdFp3Iw@ZJj2D#qTCqW{RkfO64l_6(m?9reKj6 zuG1(mK4E5do;Ln<6D9`HjuvYuGiwdk4B6KszEx8u&rH`#I?5v#Fyw@$p~LB>LCRO7 zc!>FJSlwf*$srJUJjAX&mf27F3QKxA{tw+HSFKzUcCNjB_%1OrI1A{JNv5guS-h|* z%*`)QhW#m!9rRC*G5U$0C$VqC|2^v!g{eIIWP8D;MNNj$nF9r|G{v3zHM6yrpSDZ*(f@+wf zt%ec?e)wn@RC)UEu?3RPXWea`1>+1JbmTY6P z;KyAKx!hNpX@fP0Q5r1yeKNc71Yqb0Q=tvAvRcdeU!1tfBF5vG>yf)ucq!=?r1<7v zRMWZ~in6u2$=nB#?jmFMSeo&~tw^d1*kOf`Gie64xBA=O;Ou(2MfBsx_!Y8zEslcx zgWm-#g2W2y)6Up@mGAFoufCi6D4<*?(v)9=wcRizIhKNyKI?W~a*)HYRqHi;hUqd5 z#`elRYyN!ohoHt#lSnSMFBTb2U{8#X_r-WOPvDE}Q(YLPH-44_N*<7RW;aI(aNQdw z<1I@Nka4g<zrA^pAjo$)6nU0vthrYNXrEii$6??4WPZ6ypKcphaNfgsI}fp&K@CEM-m>F&Q`x zV7f{zb2iedHOK8E+T%K^08_)zZ}ZuZ4Urtg%Qef6T%Dru(90|mIdQVjZm5<}ZUMjK zDCs>s$F;v|6Ut5O^P2?)N+94^D$K9qEo1wW-~hDe-RhBbrPvEQC z+sog=C91j&GuZxkWV`+UkQK5L&pmpniVI!ypUKiT;?DsP??Ti^vPcay2+VspX}Vu zZ(IKPP@#%*Cof(%2H5QD4kL7(Jb4N11ziChx%6jml@b}Cu*g@)QaITOgYjn^9DJ;A zq5#7J07eTg1~6!7H!bG&&?8OvsSXaAl&nPsg>ozeR8T0=mM?@a0+^8!R2z|67p>$v z{lY@UcM8w`3n#50`GXS$cYs<5AB;tS1_18A&}*J~cs|$qCSnCDm2IM7XhfnR?J?zt zV>bq0HAKV6F<4+>Af7>#ayPp|li@&EA+iocOjLEsPnLNhv5+1CK^bVjr`@;nL*mAm z+(hE&vT-!O&rCcL&+B znu)krlgYjaT(3JL<>PzdqO6Kk#Xs?Yg(^{n~mpeIIM8+srLYwnw zZB)*PTN8ybQ$9QEejShv)i<}4t4f2GJaz35#S+FT%zFApC65h03um@IT~}opq?TT; zAwqfm?VBKKUMKOYgrFrOtk5yZK+%dlEtHf#?Kr+471BM^&s=`xXV0Sd4za$0uDP$Vt{PeWuC8RW!n&hWSU`J&j7l@k zQ4_B%ZfKV{dX$Q@2;01JckD2phZr^AzoYXNkIswvvwWM5SE1gzvkU!aqm?ov^;Ky3 zM02NxUz8p7UfoFy<6=-6cS2)OY}<=cgqrLrUQv^?XPmQtV()R_dAu4`<>Eq@yigqh zPiE?e@HrsO&@Lh5B6*_%Ku(jYBpV&ll*<4nv}yr;#NycGhOTEa9E|VwTYIWIJ-w(l z`~KCM;16HYI`qvAym?e72W2*AX(#Dgy17Lj$k9q}yr97mXUcD$5slb;RWmUZ0w@k3 zLpUdE$$$Y4opXkMXR`!vKHJ08uE%+Kd%z@FSX@H_ev8y$ud@l|ds$4faT?mlO4U2t zG7U=P2nI5DWG12o1mRG;4-SQ%cdhH(Uus$ASl-$PrqYxu&q@I(IPapW)Ut$(jBwi% zi9?F%R(}>YDW_d})RlYp;A+GAQaSCGbbc?d0^_G$-xmqtF04^~Xy(bsYw+ zS+1SD796a7s_HckU)4x4rZgE{6A4@87qzw$9w8BMh-r$FQV2y0du=yh)S@$El#~gR z3vUO9+&F;qS;>)B1Ws(I$q}V@pjZK};$H9uCnci&f<{Gl^X3ZhT0lQTjaIX|w$N!2 zB4Y!;jKcIufIU_b^aF&z=P@>x-#@1Es4!gu>p*OJjMi?#Lnv}!WT)LpTmq_90h6qU}theh}SoT zndb)6ccA5Ul2n&k2Cxdn&*B&!?h(EZi|zs#T3J$pAQOf*J$i@eUU)%d*$tj`$#LuA zgJp{{b!%R3+p7&YN)cd28>t8FNj(L~GE(au8Z8ZtG8f8>5mg64e{|=N?h&6C=%$cn zk$Vvir-5q#q3Em6pu++%0iWx)6FYxdq?<`8)hwZBt@?ycBL0`&C_3B^H!M8 zK(x#>K)OJWJ9z<4Fpmo|;4C@Xqa)dev&c1Y%7d-#WndLh=eV_AJAyLUt;s0-#0!8* zp=Beb(%wV_iv#aa792=9j30#(2%*2iTo$LUT*v--S6Ao!p*>zYSO%+kFA4Ip4M~m% z1(aup?RtT-p*eF+nuBGhTmg9O32&65HeFpG^WDP9Gsw-_nR0LUc;C0#GguLl2)c^I zglCI8BXt^IkP@S(V#B)_u`Qc7lYSi#Be;%zlGh+fL1Bex3(y+|K7a%f&)k|m@%_7) z#-F^;t9mQ2<#yba#9? zik|dM$lkrtip4}h2fk$sLIF%XyP7ckQI9%z-014 z$l*DC5Y#xOOk8;4JBCwN7X3QU=Kak3>f({2wZ8FJya9dNctiokc-*)b)jkSf3m~un zxOc{DqN+!O1y3)6SFMXZ;b!57+X9s$%FXN=s3jg1>mu6VEJoIOOwXy*{L}EYfAey5 zPU~(zG}w}nck>bjimI!dnuN0%uMZ^GJ%Wt}S9U;o=;0(?Cnms}| z%G8u(o+3|sr|1c}j{IK~lHcXfK>}+>N%fS&?Y?}BNOzbnvyTrKiN8`$G5sS^9?hB*;C@tk-ILn z{t`&YFMV#Q6gOYpEme_8tBu_|cp7b2b#)1#j9wI^j&2qpy4g{AR2gu=2|t_>Xra$` zE-=7z&)C@Jp4V@9IOeo7y5e2+S~5JY4JX&#>`D7&jR0nLB=RGAZUfK^fGB=+#m(jR z{2I-avaHa-bfH@wtKCav;MhVyeH_~ct?KN(v0Ew{8YIxqH-w-;Ectw2vOiNg_4~+@ zdf>wOOIqey4Rzf2s&@=Y7+FyAthCfIL^ylxm{ru=oX-k9HS$@0XY2*=p>-!;OH1)^ z_~FxUDV@296pZ!~nS0n149^RhV~&o2ShVo3aJhx`R<-nHjrjv~qRJybHau^h2m5m= zKB%SGzW?%KKpF)JeX;jn%>~f4uTlV=IJ#^ce#+T= z$xrm6DOkuXyMvO~a<*>5@yTWsO04aPRW*7XMKt^oGZq=y**H|2$EHgFfz`cwe_EZs z(NTJBwKDhz6dZ`NWiw(j4WdyLC{KPlcrtTK)X}3uqh2)mfn@?wQJ%fc8u-S?h3V() zs4NXx=$S1+p6o1mqdMt>x)QXH1TT>H{b`%-A6|opO+p?N#aVW@tym3F!!^7P1Jw^$sJJ`1j)7W#j_YE7Hn61sJC^39ud2?ru0BdL&0Jjm=! z_xi9nVo$<$KE5Rgg#e14-&qK1ujD{rNeK^znMh?{A2knLgub4hIaICn&Z{F{fx}4i z!b>~6Mk?*$qu5GQDko$GdJRk~3m?hK$~C~6QRKoJNdJ0va98d(Mw|V zAW}oKhJCm@0mn7RL8_&I04FM19nh8g)S&g}giQV{-1~g)h*5n)qb>4gZ(*Cln?=6Y zQoGQZA@Y-VFs8_VdJ~7QWrm-H5YA$3Y-~i`CP;mG*4B1Ry4sn-Ld)uouVAg~OjrI? z6l~E5uot~ys>l1pu{B$=slYvPPtr1@kB(x>bCp>SORQ4|7qMn7ypv+O&&GUCp z8cWf(pqHeIXGgEMPB3dP*2SJJnw#zMHhwUVqE;NAwV92&RmV0_sFz|c(rk7598n3x zKi=Ez+o_XBGfAFEv>r^ zJzG?zS760K|Gw&*qsncs2rrRrX33z9{n&l^*!L0s-D8iuVl1N)8waDtkB!NBQ9j5Q zX;Na(3Kmwlc{jMZzkli(RrnJtUT#udYsoLHG4(v$ETt;HqI?HU7UnSQ$Jtk6qV`&c z?XXIxV~MM42-Qn>E0;}I(^)Q$Z>HVK538Go6oi#2GRr=AzjoEi;}S^ox1&wQjNQTQNzR<=*#*?!u}`ovm|p1hOdK8WhcG{z}Y}m@)GGB05{zLQgUr?F#RIQ1Mh+ zy^neS*u=!0ZVkWUSYBbuhY77(Pje5Qla(@;)4Ip*d-c$Iq6X8I#6tzL?L9aPH9bPv zzp8W?Dp&CGUs8(t~W z&B)0!zN4ERI9vM-N7?-01$}=6r5&z(ab8?0ayh-AX6$**x*+PtvuxTY*ZQ9+ar9IU zkM^Cj#$%)8M)rC?AKHtvh6W*fq}oqBS@n9C&IQ+oS-17lBlXv2P#W%jd<2%Y$LY$s z5Sf)mE52^BdB~{Wd_OB-me;U$3A-gV%a{Gu(vVu(Qc*tHNZP}6O2doU>n{H>ZbMbmE@WZioa3{`HA0_AMT}HQ z3XIIjIlw+-+<_1~ZzNRh#n6yMAlPTc_K_X*0I*FGi-dJ|(Bn@Y#uF zVxr2A1LM!U4eO;3WzI}rH*l|Vpmh7-p0!eARu6^BN^_R@EEv%~kj}Emi#zuqYl~@7 zwtZ61quis%H_$%cem831TCh_1*rN?EDvJ5%JUVK1+9hN6vlDOYxy|A>walj3Q7u-w zI8A2vFMzV_^QP}398%Qc$42A#ROJ_&KU}3|A|6mJ*PvpXo^Eo&G;e1?>oI-}uNdKw z4R4o<@yqCce2@{7oN>^$NgyKLacD%#{ebGl$16s+s_jrymZDl5FDkYyR88rS+2|x^ z+3dsR)o;XcR9Om$kgX z3;O!Fnt8>?dea7%QTQt#V;Bj3p&5mRZ@}4x&;{A`=lZGwJb^l&Koz=@txA?ud03TU zE&;x;WGIw~M*3o)*4+TH0~C{>8HYVJ+Tbz;Aio@}E=gO8;7y(W}J@<`)?E@Q2G#w zMM{d>@#}jNWBGTgC@Pu*Q-Rs<>70-0B9~Bcp#f#{g9;UNH(?~j!$%CZBEzFs0j)vs zcg)TADGbFi>r16^Lkim@crn@v zV=HTT7pLw4WPwv|@de<{BBP=(Dft}|7&2#LM;$X3<7rNaFeYUb{N+Hv7dS%tjSP&g zAM7M}4rtQ0OYn{wn+W`rB2+9MOr;GK3Fx`l`~pjlOPgOQvUnmvmCrF?FZ;nAA|7x- zi!E3@=cMoT=ETa&#IthtNQ#lKTJ3GajPHJCFP?j=4X_*%$a>{`a>3kMIR`-*?e?k* zGBb!~Bk+FUIn^M(wY8SnosW+yutnrevV0OU?vsV@p$4T-dvVtWRO*| z&E&!{h24^i-EA#g>SwP!wkUNtDQ`~Br9F2});=~gG(^x7?xPQ%ck&l~(SjAk!Ww&y zT`w!>Q2Gs;bViuJ!&`srr*cO%yf!TGb^53y!y_uFxlrfAE_JWZ=ey5$WzQ2Fl{h-* zsQ*5@51)mj)2`6O1Q#rtSGr4l5zDtjUEk`Zo~!x_Z`dt+?T6Z{>)kvL{<41hNdL6e z6Txxs@O{sgw-1@>Yn0L1=Pu=Xp((y{Sj+c8?X$Hvza3iJXTeTA8+g(4mf-wFfn|Yy zp0n7!Pp-2S+VcJ=XBORz|H`FN%~d5k;y`A~)I8FxJ?+4*%z1R*L;V}Y5;d+jT!$rg zG|3svlJu6$s@$8Sn$rBaKT?lx)(r7aNy{x|&=vm?+3P!2g^kZ6ZFmX}T???E(S`48 zALHtSNYnZzb|=Q|Am;#eCH>0+=T0zDhntvz0H9nZ!QB_5zX&C3}_~b_M29 zNIcl}rF{~8X}*_GS+TPbngbka*5p-}Wrw}i@$H)!Ul8oT^Od_{olBK(cDS)a6#j?L z90dVuL+67AACwUKVP5QdL=tBs$O%By0582-ad%S~M98o)tioVO&4pY00x+os-U(D< ziHrg^tA|GvW}r>^p-qM86_c2KAt_4Sgzw7CB+eN_hB|D0o1(N)tm3M`Rf#0)QkV^3 zUF}bUHBtO)XP!y-nWx#=ZQ5+Wz994hyTX#*P~#D4Ev$W?ZPpC%Hc+xabpvH@xRyA= z5FU`dD2~G#90h%oE>*eJ9zNc{&{33w&H!o8AWoH_pU`UW5N)<=%Uw5j?py$9QkC%9 z1YC&$a6}^X`vClRiN(D_?f$R-Xp5$*&o6UZCCVwf&6}qH8O1YeOX8AeUiML!?1YH{ z59duMy#=`$@=5nYL$u&)-P28fx-Qx}{Pda~cP+s~w(p+3E78`F@@Ko{wSkZO)qnWR z?|2$}reywJuALoSY{zeE)G@qRqc+5=O2uF9O5XoKUo3q67FowFTNmAOIW^#7V0av|f?UZ12>%vRgf%ELQMM$VdSx74^1!eA>5lWt z?i*Ca$KtfO%F4xOUhw^D@+P`yFl@?l@ zme?!XS#qLj=;M9}cFrx8Xl6g$@M=@KP=v$VyBijNerI*7Vp2k=V|f;Pq-hes5 z@#&XEio{>e+<)`J{Vgl9`l{lu@jn+YX_RnyvU{6k%c|%tY)w((Kjy?W?EKSnZF%3R z=mVcbg$KiT-`kbg@}fjM@wJ4d%)XriWj`!x{^oGGwq$+Wibb*3Z*4c5 z%d+M!V^up8<+Mdpl3R>t3tON6^L1gnW~^LD@A+EembTGOl-u`>xlhLS^?NzXI;v(B zT;qz_Sntd@F(x z29QbtLII0GUIg`j=1e&FR|xq4enuF(2f{A}8j19_y*OtuOB;Jrh74(j&_Os8kuP+Q zU`>&NS4UJr)Bq-18=xRyGxXWZ9iFtPD+;$Em1E5VH-~XZi415$uvH+w8=IP-dJTf? zM^U&;1`-r0--%*#qp+YsX^#@lFe+87-!IK&MtFdjW-LnASZg5yv&9L@S! zUsfpclK7%7DI9y~^I65BRWU9RF*(;;9rk5e z8E|^XYMv1Fsg}E+Y1`+&c3j!ls$4^IFk)dEJMS94%!|PmdFJ7)=UEp~9XCI0(Cy@_ zySTmbEK2Tu&%~AvaEFct%<(d|pj4h+U2n&^hOaeOJHGYm<&p9iGtJGsA81FNuDiD{ z-AaEQzs9we_!Wzm+}|X$gX8>REi>*# zW#{g-B98_7dtErM!+Anvpd>*zBk8@Aq_+Rg+G6Jw{4(=qdt&nh-Uz+Ilz8aP%%QH4 zA7M_^A6ljzb!6{7S8Et;e@V0u()L8r!o#gn5x`Yc{dqF9EQI}fo$$=44+WRTm1~#t z%k9#bc(GDh?Ogo#)b~Pct$g=b(|haF=2vc67NwS;6!UpV;Dy>fmxtc5s7>6n-vsrL<2@i@U>t7)6P>e4gP?-DM|N{6wbT@ zt=%6&mD6T;W>O#2oy5MzojMvtK=xT+Tn2q$Z7tnsdw1JI=v55Xo-e-b^65$1)_-@n z+#}u-RkZOcmk%b>U9R{C)k(@|8RZLu*`oqAR?w0!Oq<6-LlT!qc||G=m)H(T3^N~f zN5`M9*Qxs}+cE97JikWrY(<^H0^b3p=3_^%mz=s&!DJStnl*B&s6>5@Xxoa!>S$Ex zxNt&0ojfjwG+O9~L@yzJ+U-xPgfh-Ba>tUEOrABWACuMYPgmIk+n-6I z%+H5Y2lTRil*w4;D##x(z+yk~vDa2y?jwbRW-bDx%;RJCS!&%l1!{S>&Pio!4nxMA z@84F%C{_*sggccF(evhvOwg?D$zyO6kKx@>6l?0kT+jNZ=&%oSvW^5J?kkN>Eh1_ z+qjQ}_D`4m&ma5#uY+}ru<5(`=O_Hf|EEFC{C^e2{NGT>KMm_A-q=a}pP-bhmtOoH zP2Kn3QOqwC%LOB``Ui1ueJj5xa4N@&)XK$H*lz!6`{R#cOX@c4po zzdx7%j_w|m<)ClPZl*NNopLwTsdf~yExXtHWZn(d@q5fjPYK>%9$gqCbo!ph1D*&5 z9msgG*DdNm!LG_vN{n^C4B7{jZ<_w$<)OPu9$ZcCIqh=ddP<*s=HbOlhTL5C{E8J9 zvNHR7rVbubjHGXodpKun!dkw;uQ_>J_hjgog~qzdgjBc9yxjIl`SBo|+ z*YEY={vZG6exCa{p69slG3vU$-_Pg0obU5I-*Nx<$jvzL{l*&3tn4E%EgXEfrB0)G zYu*IeD+e3voKBbCgs!+b?N41WEcNsI9I37W7|)b7yr*sdZKu$ubDWthBJSR zUle}6C^OThv_W>uSo+Y(=15CurC#{2*H&C!YyKeZWuxAvL2)1So928y`+xmDYv)}1 z;|xFki6SGUHOmg?*A8%ucR5=Syd^o#*9K;Cs-#601-$|6skh6YN_J<&p0dCteuz(CX_XS=a~OW$kaJahm8D0zP~ru-o27xB377 z=Skg{_p+$DW?jA}e!lvBL{i(iFiz7c>~U#fw_jW8{wW^tcl0wIou=N9Z+f;i_HA9@ z72oI>Sfr!!=Fq$Ri$DLgtj~`JJ|5Znr_YD8Ndu=mDQLf3-#)Q)TJwEbTveopO#Yjn zf0lPr#+m|o+10lR@+{l8Ib-h1HT>rI?UZ+3>8|3XVcGnCr`)u@rQK_Jw&Cp7`WZTz zkpFHpMhSf_?bn*qU1!4H?0b+U4(7RaPICR)6!*%gIKv8k)BkZ%isN_6mxaCF!Pl6e z1YQZh>{EfP{p9zUTHAR=%;4hC>7@<3qSmf2xn=y=$!5utiS2zI91|VjO>c`D>U-ez z#_hJpUDcOqU)*xX@xm&t`UgIR){^Ua)=c`C!|k)vS)#7=K<3rD;GJ_17{Z%O2ly3{ zFKMJ;4i4P)Ptq?4c{L8B(PGE=+agfI9$nBvQ5KBxJ5!_s^zjLIiLh?aK+vecSIFM` z_34=K3J3tyq0+n6T1=aPOpng5_x=!r*9`M$rkURre4F}N)kOS6RO2Ft;MisJ(J}Jhu6Ujh0p~<4fh-e7{?aM z>{ztJXGlobhV^kRly;`Ae7yr*8BHV=@i#)n3-_gTzsQcu+wSR`Uh(kq%x$}^pM-cU zUa`Q-%j4DA=z?rkH-}ejI)6PUZA@aw|EyOMwyC7SDr+n68G3NW5_gB5DkWMcvzm0< z=C2a*y!(B7n?v=T-zc0m&72Tvo}YR@x{HApDV$; zY}MFPmKH6}PTLrUbn~W22R?&M`W?+uYnijYrF#+#yW4Sbi!U#C4Yk}YR85P)5m+UQ zV%{_4mXo`~LcNDOl)Iq>@=>@{RaGVAUZ@%28I@pvM^D{$DeT3%(~sD?_v^2}LfnP` z0D=5JkomAaO>b<#;84K|dmy?FsQcA#cSamvd`+p?3j+u7xAry_`f|K5u?1G#`@yj3 zPF7ZTwY~QjsChgrExkVNCl={V)YX0V@Zm$3xNe?*ug?*@EWW;?>NAUsz#I{M@s;@AG&~$!JEeyO@7@*jc!2j{TSHBUSPj>!#d9M4=zUp6+rBY;K#btvKy(t;HRb5A;%2v{qC+fYjiKdqL>5o!0%Q-HUAaIq`?2HV1t@5XsY;|&7uVo4qkQB zyy~WX2bjE$MFcn8b>;Q7H>DXmu-Og7=#+_B;TcxTpfxMl)7L1z5=mEkguFujz-fbL zbGo_Ekg$bzcxqwRuYj_T^R$*qJzH!LR~}uk`j9OFRqFnjlSBxv9<642y8c0X>I(P% zVwNI)OD#TplhDWrUq zZ+vf}{*%jEH#fI=cG|yh?Adhx^Dwh}_N%os{5Z_3<;>$s^ISvM%iBzagKM)vuHU@K zJ@@B(2EA_Wdur0)XYo_ZPhf7cp|^`+2XG0mZFV8@sg;&uKc3AX;5nt zL+DL^9D<1m5Df?(b?er9pP1~AS`!k#^hJ_jr_*}34b2=1P(_2LeORU)0>-8n3a@a0 zCRU`A)$-zEyz`dkprE1LVw6hgV{}v+ItAkM#d3A%x6Bd%=*-+o`kQ&w8;7sIhxoO2 z>{NmRxC6sKBnS}jK9-jwVsNzpO+XX{(k3Da5FHWiyWWrojJc~KDWdPJmbGil%+Rp| zL#pj*mlcG(-Z6CD(D(89+K7iPNURfp*uga03)R~9*VNPu)P7h?~JIOzxu&nfmZ`D7S#Q< zzQkSySRX+&Pfdt85s~n8#v+dX`P`DpT3W^VQD$1CzniOh{0!sp0xr@)J_jR-hY5m1 z_JtX}T)nu>+pguU=9zRX?56OgSuWCSF3*vl;s#`6I#{G*rpL}NrZ6ai@c_exEC6+) zZ}=Mv(*cYXunw)P0`_|gnRcsUd(!_Zx;C_p@@@^UK!eu92PkMor;di6UiQ_R{&n9D1}lH-oS0-^1!F z@!0EpuO4sv*)zbxM&rigJbM*L^c+!+BeEpIsBmb&7V(ob`NuyzU*yPKUaPWZ&iQj& zxc+ep0$w$@(Zt;26mr`uFaLJ&+GTX{?74FmjJbqb0Ko_nD`x9b=gKms^tsU)oTKT; zwK$OVs}p0*;f^A+MW4==z9sk88#H)ce)`|u`i5p-6PvS}pLT7Qq(4A?eLF~q2KpYI zckqvq%-s0w)LlF7K5od=hWdyaHrl4*${zGxXRjZ`j?Z4jTC%!i5skL(6KCtOt9|Mp z3|{9uL}`d`hqDLvSE~9nCs(C}wkj%)UAOu$HGN!T{JGEPM)m!(Pjz?mvzJgQNUe?o z$3x2|#X@lX5BofFM>*4cr1JUkF8ACUH6h8SztI0@rJcKqjie#;g`S^xMVz#*Tc?h# zp7KZ!$^OUAKNmJwjmE%6@XHdi{^C~899I1#w6gNh)5=dZl^$)LhFITJ1Y>OBJxjJm z^^Ls2B;KfTLTcmtx)_t|EYD+ObDaprF$JE1AgA9r2TVS=?m}=5`|m6@0FMCeJ_W>x zFI})YzsY)8!29|0XFEbBY zGQeS>X@RUU>22BT=t|47*TALI#q)4{C2eot`HYoO<3_>}S68ZSpbo%iCrHxZ^ly?d z`N(W*D=cZ5^D5d#ZOCEgKq`BZyG%ydW4rlmE31&0XAg?PB@K0VBMN*sG*;$3ZHtI$ zNh^`8dhW8}44(ueM(7O62U4)VCdA?%z^!1REJ*DP?I#7EHZjRnYFQ0UGjvW@?$dg| zp_igwRYTUwjIG&2!km4Y8e9!r6}OvrS4?V9b53M@mEWt%P|d$0J2i7lOWd~LfQGh; z6!-USjX@3ip&t2nv*cH2d~%iKBviy1t=E^e@4Tn~^zW57-p6Tj>zD#lZX`z5HSd+~ z7**NO#xuy-PLHMB+u%#W44rVrA%VU(yS-{R{n~CHCr@BVR$rK6bMZ@i%_V2^s)D5a zX_Ujhsf3XlVJk`bLGg^DE8ee{Pbax>==fHFP>EnNhay=C$#>1$*ks%Aiaq^XO;)dl z5t3$>>{rCh2nVJJF*o`~Q%Q6-JDD~1zv6Q3hdl%C)dsIWW=Mp4eX8Wcqh1%zo!?}= z`{XvQ*~q80daL9-+F?qY@+N26*?S&=LIstPF@7v;OS{KL=cqo;Eqvi-J9%zR^)W01 z(03-}d`od8Uc$J7TXJ5XLIrej*1Fo$6`P5=J30{bNZ+v}) zPe)$fN8>)bO#HlV*+`;chT;bufCmZp;`A0K`y3*$)?=7LT3S3~&VXQ&SP7|x`x5Z< z7IS!_KdFG|kLr?9IS(BJBh|sy`V8=?;mMLjVH!oQDMcw_6SJAqkvcNLq#}=8^IXCz zJjCZTG+TD=JPd0YvlgVI5=lB^KWtB^H(%7)_~!q1LkZzCW;E~B_mR=XP!I(u`+83b z69G$zJs7r~L=n!cgzjd>8#8x@nUl|rLREBdZh}4B9#pM7U2H4HoJmLLmGCUn=<3kP z{K4kQ=hDokRbKjg`*|9S+tJaF`I_I90}KYgJpKn+7;;U>M-C1r@U*LlLoh+E7N**$ zmxQetW;5^{)b>_)gqB1bhVoCOd93F5k#3;TQzVhUaO^)g|LT9Hn!FwoN$%9UQBkQ} zKU8Y|X&*m+wBVZ{6~_ewUHDc+v2#bHV602rMr}Iz++Pr^BO?o!YKSpxV`Da=Sna_2 zPDzBAu+{tu(i;o}hA7{KY0P3o*#?=2Ohvw?-og84coW17J{20EwAD%5%opIC&G1)c z-H(@tI%JkO2rs6{Na3SwuR=%3pNe0VpWpW6;52{T9Q^9|gkl#OpHf*>wG@M5th2>G zu$}5d$wrQ5IWW68X;yB3cSymRF1bK9^w-PFtHZ6*?8$GK1Tin_Kq3x_h>%?l=t@^d zN1>si0cJsGvT4T-F{xrFXiN;!B#6vVv0zBk+)gntSY~A8-}seR!*`yfr-y)ACAYmS-6{Qp4@zQ(p{TCMqokyBzO+@L3hLM(X#yzU;-b$OtPiz-D7F2 zuJqkujgd8OLd<)ME;lAL>=;(*5fc*wNd(w|41EAETD$7pMY_-h7DTSu?Cg9R-J-U( zHXe-7y6Ft*@BdmICg#eF(y2_yyzJ=Y2XF!rE3?r+)Q^~&(0Ee3{N-ZyHtd8Tsx>yy zo%1@Y{GV=q)vG-wtqzffbP^BWy|J0Aftdt1f(dkl5ebC$Sp3%fd`!dmWvE<~>B~_W zg(%b+erzAqyW4j@xy+zD?(^vEMW09M|Lr~9UU~JUjOY zjOttSb?rB4X)*2>mRw_60`0&pR#s(tYX1{L0aa`)G~Im7YvrtV%o57Wdz57^o#OOW z3k#6l$ceATJ74PV=lS&F)6Gvdze2+JF4jKa(KS&5de)}L2VP(FUGKU+&UJryl_p*d zpgYA5_sa71DMHkKB=t`F)p}^vt>2{AUa713^!u@M_kpSUUmcFPdzDEG?hTZ>q_{pv z>zlsg_yq9U@sp%_i*(PA?(b&*dus<$2^tv8tTV{7_{_XZ@{sg8`(Q<%J$xb21`+2_ z?BG&-d5y;H+qY|v*+-$}gwt2|+Qghgm7bZYfI&Nhrnw^+w_?fo3`w-8$S5Vh8wP@H zv}Vbh4SBg{``q}s2)v;N;t)f_gg)C1H=lFo&u`ke@qWvaw-ad9@`uvb%7R+b~Fi4#LOW|G_|$BCf9^~6H=@s}@JRD;(-LehE_Nh3CR z#BVtIwOwsCUjrNj!x}y@{6#K0_Tee`x#6DB{&sF|!;TJ$t4)%V4>`uyy5nT0)YIeY zu}^KMLW2){1668SabQOi#Q`Jkk^Kn{d_FckBR=1_aUUu=5k^o8;)zh$)>u%685b2Z zgJ515N4EyW3!_0ksBp*N*^t5Wa&tFs-FiSgY1(l@1>g`@fgd7b#si;T6wBXyi#-jg z!4S)OHpUmQGlHjWb}U)Hs&df}6wHS5<}Bh7xd=dm4hjiL;>_LdBkrU5obFPkf{O`B z0cQ<3%H)(SZXUV47lVBrT+cp~xY3usdC}GeU&*4II0K(xS12 z&rcZPFA#f6ak7BuLcb+?((&W%MZJKT2&@+>6&(dcUT|yhK^9VM{6$%WVEEdpP{*%P zH<=pufQ10GPK`6krTq-f5~`RWJ(t+sANz9PgmLiBc>UvI{{ZAz>iOsRzhGX@sGQ7t zYB3hXfV5v`mlA86o82bFwxN5hqZT=__*&BH6i*b!!o@~ZvBIq@*f0yjF*37|0U`?f zP6&d6uyn03M#3N`$%&Zjm0~ATe3AB{8OnhMy zQ5y)$F}%fS&!z2=g+KK=hbyWj4eOx;oc>Cy5w?s2j{83|Mh6U6?tzOM1hIcBeuRUF=oCgX2BW(-FT8f5x57}L`>h=fOI99SeD`a>Om5IxLf0Tc(Ib!LbeST{Gh5p;jcvt5%=WWqz{cjGCgm?4sy0$v~ z_Qj_s^lFPX#%uf5&(ZF+lR3rM)fw*3?NX+$x6XN#aOSN`@9KMvV@=`}s;hTCn%-dJ zyYlM8wVp@wKPY&7G&pfwsoR81cPEVjY$*Sou*k#3GFRX(K*hpEhw6kjh{TT_HY;{F zQ*ibgsCn!5?FWw^A7~uZ&F^sx=2f?D-A|UcB-ecm3BH|>(0d>Ksuqqq55Af2n66(B z4WwO$j+pWa*(|~5J?>=is?~CwkU@B1i!K(IMB88!$8r#8aD*si$(Usk?9@EI{N&YI zq}E)UKH-;Bi? z#((eEk0ErTwpZr1oL@YuujqFLc5CKT)$q_y#2GyFx1VPau`)4c)0&CO#nsg>hRDR< z#?Z&jI8rj}qw{Nz^xNQv0-?Z%U|64x=yCUv5qC)wp6baG<_L?Dm3LDA1xo|^K}t$` zC}^v4VKjh-6}G$x$u3eiU-1ufqmfLGRPMbeY}1`_pLrrwqQJyv$J!LVnmV(EXE^b$ zgVc>JkY=}ZgR|-?%;o0=jD2)|u&X}k3EP6$Y(R3tum%W9Tv4V~yt#W|Li~vkI3y<< zA=7>Q+-ZZriddW>7T!IB49%@KdyzM>x9#m`Z8k+pwPpmbi^IgfC6*x&Eo&}Dc_{AC8cbZ}mkNdyY{}!tk6Y%XfBN((XY@n( zm;y4>;6#d-ae4WZz^0cw?y{j+ybNJA_yOy_$M!0+X4u!!Hh{PUK0Mc$c94B|uOf=O z?{`hJ$H7-x+(GAjhZ9+4jESfic?|wb|8CClYBS-tKP{`1dPL;QoC_pHt$(-tZX+R> zVtIEqFwhd|0p%Kz6^|lXi<`O@=SSl~!(m8j`p^qB?AJuI5ICC1hQnUhuI>aiyBNb* zfmsQE$-Q4*F5sbfbla`syE?4veNH#M26nR4qX#TpBLc-=27C%kcuvtah)a6+PV?Y1hYuW2+OR;xjO+y`a)-p3Exd&_a_(SgcjOej5TRg%N zU(IT9lF$sMPe>N-bwZKx{JDTpwF9wIF4R$=?-D7Is+U?D$E_>80_tXr`KB2t{8xlM zu6A{x!4p}W=Kke>N-wIKd4B9m{y2y2#lZ5lrluuto?{L+1Y2GdwQX&QLZ{L%Ab@IN z@<%PPgMnJf;EbiG#qk|?Ki&0`2P?LJXtgNtdNm;@Sn2ehf7>gc`5S#oYfJvy>s{T9 z(4m-9PVCXtxr=w@%s&hDy;>?(c(~@u4L>!jP93eNn(EzRm=UR;aj(x$ZfoIQ`lk(2 z9Xvv*=Zf%?WZ0Sqw-w{ZfR>TXEiJF!UvX@=mDL&i!4ni0Bz0##cxPSlPB(3ZX*aaf zW1TCc-j5SsBz2sx_pjH4`?fli^0NO5HBK+;MC|N$!l?&9@xSN;j)r-=5UR zqTTt_d)FD6w5Nb>SPF<;Q`nI{5>{bUgP2^2Mw^O`Zi}@sn`nwRMK-<28d$lwhzKml z0Q54ag@R-9ZrN0+!UfF<6Y3av;eEhsZEc82)_2}TL<47|Wnh&{V$X`oL!ZTz5Nu7< z*~BKym`LCPEAeVC^V;g^shZ;?yw9L3i<&G>Q{>>x%*pIeDAJJ*WUG)R=z$0uf`mu# z62%vViLgA=%CJm%CVUWq{znHCQr1a}uY|k~?_y|RkVy?e1H_+bo?%S_mnJdtuEt?d z3t=pHJMSNp{RPoBlvDKe0yd6w4YX{K*1jn&2`D*J}|G{?M8! zSU{k}xs|sU$m0ioO2Ppbuwz7E20kPrN{LzwKf@{X85B6{Y!gd{n!ES?bNhK6zf_D8 zGI4ff(!$sj`&7V^#s?2M6*)P=(v`IjU2sqV7OJhiZPMJ%-Q!nAc-D`Lc;%`0A~3x~ zU_QK5446QNo^lSbh~Qc8N%_=JcWUv^;bw`?IPiyc7{D z*JwN@tBTCaAW5pjg)sAM-hwp?wVANIl`ftnwq*lv2+141*Gu1g)n-CZpC-ZrC(E_p z7Hf&H0t9;!8I!6_@c3KGQ`?V@U|vVm0Ob6Sy)HF8NDGK3QiO?g@{C=4D&j;HM_fV! z?{~Aye>()#UU4t=)(+s7Ez!l*@ZlreEBKw0mIk1X(ir^c z%Q>F$vul%Iuk9~!RvkRiWkXI_F$V67M)w=_;fE!`AB_`d%+O#FNTf}#s$K=mj*%aH zT-B52@Wv|L^AIqo?*B~t5F1NlOcRH15Uy@ID$_q%eg1p#)q=8+yd6Mm(`RU`)HGoV z#~=@TrE^P8RDK2yLu;Y1*8tBQp;}Z_L_x{ekdn5xxpTxT%a--D=;W0fn@=p%VntI3 zZ11tB)Q6tNpvJ66WxM0qr~GRFvA;uQ!FdK6hBWfg#3cbpCV|yn?_i6T5gjbQY{_WQ zv{=76iPB4}G*@VvfC1}dre^uTE`0s+WeOoGBKhms^oyO4_-pv+!)Y}@uHF-Vj>Xv(bsCgZHYZU{3G9N!Rzc$b@K5AH z0j5JD5R4YVY@rNflqat7)~JuhZyA^J@oOXn?V<^Q8(w7Wg(HhFUcw{V)Km-s2|>^& z?$H`i*pek!<&xwd|+@|VzQtTdc``9DDQ!wiq)fcf|U;ng8y}ilG{?rT;j(B2_%5f$Fk=N_v+ON6Q@J~aSjJZ9z`?1(=E(|6OP$; z;?CPFkd#D=zE;4&P+k^a`>^!b_hAxM&$qo0_|t-D^XtLTNSj{GXS`|}GD;kX8qv|w zf(L^a3Lr%_SXJ$tb+(hHP8H72-!+?*A3Q056y(axpM;}7AYvUo_qVkwf;IMZbHYIt zBBy8?xbFsSUw$Ky171Tsb1gy^CJ;2kmb@$-u)l+IAFTW38efR)l9IB2TBafx9(Uye zzPa$a#BKUE{`ce0K~6F96}wp2hX_HG{T?}ZXMI3?{XsC0xRVs~`2JEF2%Zex6^2US zOQi#oo4Qh7Fz*tUw2%$B*sbl2$ySuYq6vZ$K_Pd;a$N4u0gE8VJP=OP!ev)oY16iC zPXhNk+u7k|`d<7dDyC9cb>9a86nRo@F&V0=;*umg2{Y3PPvmfxFepoO|I0=~>uCFG zzSiIpTu+G~jG@%_sQuWbldvDAq7Y09a1IDw-=%L)7{-xai4^SJ6}TLg8(Z8fa(hny zuc0ND`B@)a0fbE*OoH25Ss4=_PrEw!?%JW9 zpKR4T_-GTor6a$4t4w-rxyB~bpHCd@LM^ay(7(`>AnCLO0u!rV?l-Die(!Mfu3JSV#PB()x|0(uXoJtze z;s(c)3csFQyrpZixt(9d@?EE=|29O)8AuS>?SmR^?Iq2HT#&j!HFN zn`L+z=QzkMRf9_G^Kd@PL!bPhe*H_t(eUk zT7emsvzDh$q}t%AG5chsB6=B?7>KQ%F!enCS?j^7SXrYZEhA@>E560jGBs>dZ$ib{ zvuEFndnRrYdrEZ_m@1O~z$WsTU;&U5Dls(!*oF;-Q^IuWYurw>K`Cto;pf^1=Pjiq zXQGVX&G{#@$`Th#s1xW!&I#6qh-4nLAeJ-?->j2(3GqyuQhQDJ8bbKnII-NPZhU%e z=!2M+QXK_O217f9X4Bd$$L8K$o3V4;H)pxi^=GmYjF7T}hf}19BwuI^Mw-7oed zP$F?MfDlT6VL zzb+A7zZpp^zLpSaKv*Ig_S&4qG$A(j$-n=eY@D)~R%i8zZ6*_ve?bwx{rCP z>sPd)JK2||00h>AcB)G;6=zKaX@$8l!w=za511vL8wd^{Jnomb0fmZD_q62ZjF+2> z7p$t@rB(o$hu%^!yCjld*(1U3Ygfav=Dh|$_=z^`bHdo3lrQL1#3h1eLUU(`v* zt=86`b0e*yOgUxjGD&g?e;YRQySc^#-lv${&`gNz&tMWR5fj!^r>+|BW!3_Y7s7r` zHcp`bkGX>Nn{+xLIMBNt?i&Kyy|z+p2iTKXb#voO)p#&l>E61fMB~c*&XgShQvK9n zS@SX&)023`(iXsHI-zQ{_EfL;bm3bF+V1+|jR+6O9e1&C#DRc$9DG=4seB50#^;eO zi`11q&^?0{OjzRugN;An-`KQkmmlw#&a#;`teK*KgZJRK!3C}{=mGWNmt1#NsEtH1 zaBwb(jh}5>I~Pwg+!pw_R1+P5gU#HpZ^wV~5nEX@C?`G!RZ#P>=BAnD3=Ap7rxP`w zc%(4A+$=@8#o?o3*jw{xplGHU1KIXJ(J*#26Fs(p3o<<)YX8-Pmx0-stKe0@((CtP zc)|K|=^8F9Ovh$6p5?uLTPrnW3ZDEru_YyEr{owKl{cwS)%KA1g{_G*Q2qh1Hu>Bo z={9hKbheK3I>ymf+|QZafzyM#Aj@Bv>AVX&IS3xgKFV8YG|;i=!)a3QuQT-Om*!+@ zwsx!G87sR>!AnNZ(HhJyBKqd|c=vZffkj0|226|Cy_0G=JY)0Zr#Aw|Fq0j3L1)q; z7n7XCOFm$lf2|L{1?i*Ae0|X;e1#d~+eduZsiRW*{NL@Kz{)=zeY|F^+31*H)&RVc zNaoB?r3VzYbJx~d&@ljlZML>P35iTc>9MR)t!u7I;GUb0Q)cb2$Vo1a3JZ4Jx%B|! zs(X%B&6T#Eb0(kTv28qW(7Q9BH#zm2B!lH9lQEK9d1DDCdQn%sP0a4W*(qap(y*V5 z`t%&?6Z7e4H-`Q}l^bx)>$gi9$4u_fz!9k2mjvsl0)Q@I)ZrfQuGDj$bs9J9WK-g& ztB_YUO(-+i3bK=b4NA?hm)m-0YgKjq(VF2LNte|R)r?JVVWE9_&8m&7=OzV!w_q=r zaBPjOt)iH|-k(S#$ElinKI8hpQSmrUnt4?kYhExk(&?MhAujIrmxlTiYu0pWU-^@% z*$hq1@o865t4~$1SLtOpsp?dbtx1Wmd&=IT^7D4~_WABZ-pQK+E@q=>AMo@{ zoyxxoe=g|TrH6rAn%ePi>sbs5u%kh2NR=Yjqoa5TXaPeGm?L6D_1m{^b2@of_9+4a zU~>GQjSix$e(-?elvH3(Fc2%0C}TZ5+Sb@jRqm}y(pc=)0_A|NgxwKzTVztkt02!l z_Kgh<2q-QXc$^3J0+mi!{?Y0d&>h5EldwPo?0f4YAV@af7@2uG} zc@Tb+Kf|_3nDnf~A?-n90kYrf)t0OWHB*+ie|pA+0n(^$kl14So_NVQ{%r$wJo7&* zNFczH$iJIIyuOYR)ap%)d6w=N(L?S2zCft4z}PC3k3s>h_;2^InJb zyBzbiK&j{Ukr$6mx4LFpdE|OXe8{5ct79|@d#Ybb$OwJD*Gy^hkYOFQ^ZfEZS4`Qz zz3<|#6g9GKUR4&MEpbqsFo7Tw89D7S8r9b<@=qJd z?)VLR5-$!s;^|P@f7-iGItuyTvvWZ5yLor4bPgo6x7Vo2Uyf5)q2#lAr)x6oY{boa z6h22{#`CS0j4pEe;Pqw=MFL{sLYdx}TgL z%R?jm@nbr2gy1MdF3Ct29)tvYJ|ns?HsX@OLt$qD%5g&G+M9bGX`H|Qqt%^zPKQfM zN=8;_oLUScs3S!g+#IOImn*&=oYl^}nWf)r%*`=$Vuv69WkHTpRY9`<6?KZ`<(g;s z3It~s41p_y1JI2V!?l)*)$3w#_wwb-kT)fnhpLO+iUydihexx(Ulmu)du%`xMT!{R z%z~W&^-c6AAilJlO7kdV3*FBw@aMa-bT_-A9%_TM{+UNc!ybKE)4j6G1v104`zy;e zlrZ3jBC|CLf`e>t_^PqA1mDHgB@=)wf?q*~+g!L&J8?`w2;=n;$Ny}U*1LrpS5?%+%7S-m zrWM(X_uolbIWvKIBqXL0s{W4LpNNH})w$raV$mDSc&J%3_BI`(CkuTYYT|lT<(=C6 z5ywY%-Nc*50>mJ=vFF8i?!TJgeFYdddGZ53L;kMD-1!|RJPUeM6w&@Eg0u!#^XP_< z?8_@}REs$-S%ZXFoS|M5U(l8*2}!jK+di0PT`(5FXAQoVk2H|CI(3N9XWMYZvhC$D5tu;m}Kj}+H|Lz4H%@N*xq^5Wz`*I$lAGnR=xOq0bvK1>uWzf zy?A_LLqnAh*e{>4@sfIWaozOU-8&xJ6|o>Dd&>*8OBK2Y=UzBiWB*v8)3P>CWX~&3 zTKqjy)!%*pq$t~!VaE0sEMjk3HI&zP>aKQ2F~En_1Lr-ydIXq7EXdNrS9?RwlQPGI z`qCkyu)LRNdg5sp$g&%Q=#_?D-g|8Gi2YfzR2F2kb$k>wbV-!&mA|`pj1|z{KEs)# z15*aCY3l6Rt@Gh!A$l6h3q!>I5vl>pJIn9O6kk{?9R6XWvg%=0^ctu|7uhvR^;cEC z{y1Ob4_7y57iUoQO%9uW=NjtFNEmINwEL%d9n%NjN=l+(V?jf<*~`^BDv$XB-K!t{ zyxJt^Z`IwhGkxS?>Gmq!PCxZ@58e^7*!D%5K@X)k-wk@M7bBng4NjLU;~k7YdkFm$dryKx>k?D=U8&BgoQxmkwrSTa1nU z|1rweA`$GL*}>S@UZs2b?Q-Y%-MT}+1wJZEBOmYn^J-3e=sf>)z%2|iq7C~s7!G&w zP6^qn_QDBPW{J-mI_FB~mW6$q+@||YxlpsseF#;3T9GvO-te6)&eb})*C319o+pgf z$Tf@gsze(i8bYMD3F3r{0UIrqaN{vDQu}4At3zAWl4an42`yE^rn%vXXY9~h*%;4w zw^rpExOsBQ;EFet#cKXr6+NTb#U;99%nPhJ9OdmEs)nAQouXZ={;>0Yk@Ei5i#Ic3 zBRp$!Sz^#Ht;n?OqTT?SOg3gi*LN$}aW`R0T#=h=aPXtbeV(YYbb}N^L-%yFoz_E^ zv{lS35Cg(nOB_U%stkG_XH);;P*9-y^&Za3G(#Co(uyKH!+_2Y&dXpc;; zK~<5xF6e2Z5Zt}0zNk9-M(Cd6!J8%8C{rrx@x>*#(GGxMv76!FosaX)O^8bQyZFQM z#uhK~;|Vjj}e+U}okq5`Bjw3g#?H%L$tLzPfiuBUQb|Nr$eurd0** zzU?9(kKva+)GVQNMJ*7WQ~AUYtWki-f?Xo!@0w?d7^D1^{n7GFMO`yv><~Uf(@&+?FNZv zvU45xrEHveZ9US+qdDQ`hCP;+gN1yEEI+gV&pDTmF4Q?`WGw#d@XB4wo_?%k?;PI>>`8J%}Ct0!}) zZ840!rYe?9#r zU%x(DFf`5vog3cw;ENqGrsf43lM{?0cCJ(&G(0#9H(n z_qLfkB6Zmrs>_m+{)3M`wri^x0&(#3=iX6MrffSehEKGpL!3XS4ezV)oAS^{AuZ*q ze>ipXPDwXd>eDA;pWxXxJplYIdHZ@C_%@4C1AYtG_I2W4ZZfI<5xcWF=JA<%Q>+-m zpy^<79Uk5kgS`}04X&)bvG7lMe=9xh>#sxJhQBC(Gbp!vvh1YA%mRCBm89NP_kx;y zE5D|sbhEa8R2Z9k(P4IMY>sT@b`$kChn^R2{WEU`k@|<))LX;ssg!^xS#JCI)2Gm! zk35vqVlY2xlD+lvmg+S_+v^q;J}K%X$?Esn`QGr%B2|~BT*I*R+G#)RGPsvf6>Pry zT$Obve^q@}iuj~#eD8FUm}utOKdJXIe?LEz%QV5dw>7f0&YeBG5$S*s8%IW_eELNH z6;C1VvBhpfwiXLP63+EbTI%avGeNiA$RP7ascQSVAus$MZ33_F09c(^Ra6k2YO$$N zR#3cmrBYWKd;xjiPipz<=>3+cLP!=nEin4Nts!$M3@|tHTL-s}iFn3V2roS0i%q)D z@eggStp%k1wlaF!l=<=b*YkI&<$0yq!*iq>Vzvr7s+4w@nsmajN`uU{Jr0RkgX7`Y zgK)s^0gi@VpF**SR!z|0@7)u2`aCw;X@u@vHO7xpFS$p1wXY%ihj}nf0-n0&H%Tf5 zX2i#gP%n**)Y&C@7wK4#ZBU_GJ)+E~_qwtS{dG(#$`_E%OWru7|tbr9N1%Fi%5ABSY{c=I5?fVuF{O_Wv&+OJwkZ3&Tt(f@x za7x30%gfK{FTS>6ZTH`@4D(Fu7ga9veI->*G{fq9`D+W5eLb&??K$J8U4ghzH^AVM&QuU@^^MMbU3XHG^sqJn0iN^Yl zqF>n%+k|CfPtDHOB0o@xw#wTP9*fm)pNV<+%#S8^*lNLm!q0xu#??J)juMu`^0#hx z^Vu5aGgbLg=vYA3^7Hd8HviEmZ5h<7uC9B>=oP#%jYSaGl z3}bQ2sajAqw56?mR!vDaJd>2WVI!8iE8qN)y^8IDYv>ucKJki0#W4r&DR!Tg z-^Si73i>?~+zlK5tz&5t(-e9x&Id`E9o(zN>~72&v*8SPR#DAB>=-SUCc6I}DjGrR zA~@d!TgQP5%Hsf#upwpF$}NqZYxcdhHi)`ZXlhw|`dk?Hc&S;U0M7&;g?sh^^PUL7CIu77!Xl0mQTk z3T6wTsOaU@1%kpNHDN9TVn3bc&)p-jtwDm0KY7@h^r1hvs;Lf%88#|tnOe)9D4Rgw zV2piDO-*OzIEf9sVmyezdh0B^Gds_Z2GkMcBv@%!4Z5{9P2wTZmdw6fIj zTg@4^o~ZzI?0+=MP`7Uy7qraMbO7Bl8J1&0gl5H&_aLdCS>r|HKk7F)_FDDs_hV>Q z#-2J9ljWQCQfp~MOTsrA!k%Q4q??uU@NmbhxHq7%P;I^qJ@n)9*&kPTqoUPQ#JdcdYhuGb95JH_Xye%y7a)1e1-p3VlJ{VLi& zRzC=ScA8~vLjXm;=9RsRm+zyD>ui?eVZ37D=)ThK&9LX#AJ?~c7qN`<;-8ACmu?O? z<={hqHDf@UMQ%{k>Ih`Zw=@nfpZKJF+y33UJ_Rzp+=K}++uY-ylm&Zbn(umdDR#qO zc{|*Ke(FEZVxn?2ZwBl?^a^N7%|`>)Gp-I>@m6xx%=XyAhtSS>q)NuuvOlVHF`4~k zkGZo+<+;(6j%^Nuvb7R-p=v)}6Ny{kFltANr}nI1X&J{JkYciO?^wsQ$FjQPS!HK~ z14s7#VTytFe4yDi8n4v8B(~kw2;3T#!j@{af>@u_7YOOp#w_JRc5 z+v`_>-l3ssQcKC?L^Io1ulS#zotk{~@nN3_fAsl)<2I#Zo>a58=IFXL(MLmf963_V zkeDrfzwhsG=*lMLRE2-t-CM{ybs>%N=aqUcf3FIi{Oc2=hxb)qXzOXkE!AynxRsi8 z^-_oEPCXU0wcn<+`5yIlurn%BSJ?GS;o;pcR7>SUb+`Jql^SH2^~(O#uexh+MYG|6 zU1vW02gPG9Lhz(=V2`mamW=(fR zRk5`j1B2yCKF5#$L`}i1Hy^k0hI&uNKl~F`x{G0Hds|C_yuAfr14by45~?po4D6Xa zdi3bX{Mi2Uh96%D8hZcg0)H~B;2xO`hln>RaF!!{D)C;j;TneT7ZJ+N2d3BY56a80|H=vXUwarpk+aJ*e0bDwg! zva&J~c?{?q^J;1I&6_vvsgWU#V=P++UDM1J>qt0q-{umBXSnU663>gW5=wHKLuMVq z0Dg-pk%)N5VjYCSU^d<<`v~V(U(Q^i1T{p^1(^Tyl&mH5v$YseQ8lg#du_W{LC=p0 zY75>elvzb-{>*Sb_srP7#UOsddAF2v1es+L8Cn^2VemXJhbH* zd%pZB$kd|Um-ZgHYyZD=$ijz(Sx4%FrW-db_1?)}T+qs9fiq#8M@3R({h^zWTGc

^o`*YOPW@(%e1RZaOb+TeS-g67ft8E> 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 z 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⪆s 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 G!2bg|WhNv5 literal 0 HcmV?d00001 diff --git a/deploy/redhat_connect_zip_files/mongodb-enterprise.v1.8.0.zip b/deploy/redhat_connect_zip_files/mongodb-enterprise.v1.8.0.zip new file mode 100644 index 0000000000000000000000000000000000000000..d0211bdb1b4bb021f530259a43f467cfd8c73835 GIT binary patch literal 345416 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